Compare commits

..

No commits in common. "master" and "main" have entirely different histories.
master ... main

205 changed files with 35024 additions and 1117 deletions

1
.gitignore vendored
View File

@ -12,6 +12,7 @@
# production
/build
/dist
# misc
.DS_Store

View File

@ -1,10 +1,10 @@
FROM node:18-alpine as builder
FROM node:18-alpine AS builder
WORKDIR /app
COPY package*.json ./
RUN npm ci
RUN npm ci --legacy-peer-deps
COPY . .

27
cypress.config.ts Normal file
View File

@ -0,0 +1,27 @@
import { defineConfig } from "cypress";
export default defineConfig({
e2e: {
baseUrl: "http://localhost:5173",
setupNodeEvents(on, config) {
// implement node event listeners here
},
specPattern: "cypress/e2e/**/*.cy.{js,jsx,ts,tsx}",
supportFile: "cypress/support/e2e.ts",
viewportWidth: 1280,
viewportHeight: 720,
video: true,
screenshotOnRunFailure: true,
defaultCommandTimeout: 10000,
requestTimeout: 10000,
responseTimeout: 10000,
},
component: {
devServer: {
framework: "react",
bundler: "vite",
},
specPattern: "cypress/component/**/*.cy.{js,jsx,ts,tsx}",
supportFile: "cypress/support/component.ts",
},
});

Binary file not shown.

106
cypress/e2e/auth.cy.ts Normal file
View File

@ -0,0 +1,106 @@
describe("Authentication", () => {
beforeEach(() => {
cy.visit("/login");
});
it("should display login form", () => {
cy.get('input[name="username"]').should("be.visible");
cy.get('input[name="password"]').should("be.visible");
cy.get('button[type="submit"]').should("be.visible");
cy.contains("ورود به پنل مدیریت").should("be.visible");
cy.contains("لطفا اطلاعات خود را وارد کنید").should("be.visible");
});
it("should show validation errors for empty fields", () => {
// Type something then clear to trigger validation
cy.get('input[name="username"]').type("a").clear();
cy.get('input[name="password"]').type("a").clear();
// Click outside to trigger validation
cy.get("body").click();
cy.contains("نام کاربری الزامی است").should("be.visible");
cy.contains("رمز عبور الزامی است").should("be.visible");
});
it("should show error for invalid credentials", () => {
cy.get('input[name="username"]').type("invaliduser");
cy.get('input[name="password"]').type("wrongpass");
cy.get('button[type="submit"]').click();
cy.contains("نام کاربری یا رمز عبور اشتباه است", { timeout: 10000 }).should(
"be.visible"
);
});
it("should successfully login with valid credentials", () => {
cy.get('input[name="username"]').type("admin");
cy.get('input[name="password"]').type("admin123");
cy.get('button[type="submit"]').click();
// Should redirect to dashboard - handle trailing slash
cy.url().should("not.include", "/login");
cy.url().should("satisfy", (url) => {
return (
url === Cypress.config().baseUrl ||
url === Cypress.config().baseUrl + "/"
);
});
// Should see dashboard content
cy.contains("داشبورد").should("be.visible");
});
it("should logout successfully", () => {
// First login
cy.get('input[name="username"]').type("admin");
cy.get('input[name="password"]').type("admin123");
cy.get('button[type="submit"]').click();
cy.url().should("not.include", "/login");
// Clear session to simulate logout
cy.clearLocalStorage();
cy.visit("/login");
// Should redirect to login
cy.url().should("include", "/login");
cy.contains("ورود به پنل مدیریت").should("be.visible");
});
it("should redirect to login when accessing protected routes without authentication", () => {
cy.visit("/products");
cy.url().should("include", "/login");
cy.visit("/admin-users");
cy.url().should("include", "/login");
cy.visit("/roles");
cy.url().should("include", "/login");
});
it("should remember login state after page refresh", () => {
// Login first
cy.get('input[name="username"]').type("admin");
cy.get('input[name="password"]').type("admin123");
cy.get('button[type="submit"]').click();
cy.url().should("not.include", "/login");
cy.reload();
// Should still be logged in
cy.url().should("not.include", "/login");
cy.contains("داشبورد").should("be.visible");
});
it("should toggle password visibility", () => {
cy.get('input[name="password"]').should("have.attr", "type", "password");
// Click the eye button to show password
cy.get(".absolute.inset-y-0.left-0").click();
cy.get('input[name="password"]').should("have.attr", "type", "text");
// Click again to hide password
cy.get(".absolute.inset-y-0.left-0").click();
cy.get('input[name="password"]').should("have.attr", "type", "password");
});
});

View File

@ -0,0 +1,211 @@
describe("Categories - Advanced Tests", () => {
beforeEach(() => {
cy.login();
});
describe("Category CRUD Operations", () => {
it("should create a new category", () => {
cy.visit("/categories");
cy.get(".bg-primary-600.rounded-full").first().click();
// Fill category information
cy.get('input[name="name"]').type("دسته‌بندی تست");
cy.get('textarea[name="description"]').type("توضیحات دسته‌بندی تست");
cy.get('input[name="sort_order"]').clear().type("1");
// Enable category
cy.get('input[name="enabled"]').check({ force: true });
// Submit form
cy.get('button[type="submit"]').click();
// Verify redirect and success
cy.url().should("include", "/categories");
cy.contains("دسته‌بندی تست").should("be.visible");
});
it("should edit an existing category", () => {
cy.visit("/categories");
// Click edit on first category
cy.get("tbody tr")
.first()
.within(() => {
cy.get(
'[data-testid="edit-button"], [title="ویرایش"], .text-blue-600'
)
.first()
.click();
});
// Update category name
cy.get('input[name="name"]').clear().type("دسته‌بندی ویرایش شده");
cy.get('button[type="submit"]').click();
// Verify changes
cy.url().should("include", "/categories");
cy.contains("دسته‌بندی ویرایش شده").should("be.visible");
});
it("should delete a category with confirmation", () => {
cy.visit("/categories");
// Click delete on first category
cy.get("tbody tr")
.first()
.within(() => {
cy.get('[data-testid="delete-button"], [title="حذف"], .text-red-600')
.first()
.click();
});
// Confirm deletion in modal
cy.get('.modal, [role="dialog"]').should("be.visible");
cy.get("button").contains("حذف").click();
// Verify success message
cy.contains("دسته‌بندی با موفقیت حذف شد", { timeout: 10000 }).should(
"be.visible"
);
});
});
describe("Category Form Validation", () => {
beforeEach(() => {
cy.visit("/categories");
cy.get(".bg-primary-600.rounded-full").first().click();
});
it("should show validation errors for empty required fields", () => {
// Try to submit empty form
cy.get('button[type="submit"]').click();
// Check for validation messages
cy.contains("نام دسته‌بندی الزامی است", { timeout: 5000 }).should(
"be.visible"
);
});
it("should validate minimum length for category name", () => {
cy.get('input[name="name"]').type("a");
cy.get('button[type="submit"]').click();
cy.contains("نام دسته‌بندی باید حداقل", { timeout: 5000 }).should(
"be.visible"
);
});
it("should validate sort order is a number", () => {
cy.get('input[name="name"]').type("دسته‌بندی تست");
cy.get('input[name="sort_order"]').clear().type("abc");
cy.get('button[type="submit"]').click();
cy.contains("ترتیب نمایش باید عدد باشد").should("be.visible");
});
});
describe("Category Search and Filter", () => {
beforeEach(() => {
cy.visit("/categories");
});
it("should search categories by name", () => {
cy.get('input[placeholder*="جستجو"], input[name="search"]').type("دسته");
cy.get('button[type="submit"], button').contains("جستجو").click();
// Wait for results
cy.wait(2000);
// Check search results
cy.get("tbody tr").should("have.length.at.least", 0);
});
it("should filter categories by status", () => {
cy.get('select[name="enabled"], select').first().select("true");
cy.get("button").contains("اعمال فیلتر").click();
cy.wait(2000);
// Results should be filtered
cy.get("tbody tr").should("have.length.at.least", 0);
});
});
describe("Category Status Management", () => {
beforeEach(() => {
cy.visit("/categories");
});
it("should toggle category status", () => {
cy.get("tbody tr")
.first()
.within(() => {
cy.get('input[type="checkbox"], .toggle')
.first()
.click({ force: true });
});
cy.contains("وضعیت دسته‌بندی با موفقیت تغییر کرد").should("be.visible");
});
});
describe("Category Image Upload", () => {
beforeEach(() => {
cy.visit("/categories");
cy.get(".bg-primary-600.rounded-full").first().click();
});
it("should upload category image", () => {
cy.get('input[name="name"]').type("دسته‌بندی با تصویر");
// Upload image
cy.get('input[type="file"]').selectFile(
"cypress/fixtures/category-image.jpg",
{ force: true }
);
// Wait for upload
cy.wait(2000);
cy.get('button[type="submit"]').click();
// Verify success
cy.url().should("include", "/categories");
cy.contains("دسته‌بندی با تصویر").should("be.visible");
});
it("should validate image format", () => {
cy.get('input[type="file"]').selectFile(
"cypress/fixtures/invalid-file.txt",
{ force: true }
);
cy.contains("فرمت فایل باید تصویر باشد").should("be.visible");
});
});
describe("Category Import/Export", () => {
beforeEach(() => {
cy.visit("/categories");
});
it("should show import modal", () => {
cy.get("button").contains("وارد کردن").click();
cy.get('.modal, [role="dialog"]').should("be.visible");
cy.contains("وارد کردن دسته‌بندی‌ها از فایل Excel").should("be.visible");
});
it("should validate Excel file upload", () => {
cy.get("button").contains("وارد کردن").click();
// Upload valid Excel file
cy.get('input[type="file"]').selectFile(
"cypress/fixtures/categories.xlsx",
{ force: true }
);
cy.get("button").contains("شروع وارد کردن").should("not.be.disabled");
});
});
});

View File

@ -0,0 +1,151 @@
describe("Category Management", () => {
beforeEach(() => {
cy.login();
cy.visit("/categories");
cy.waitForLoading();
});
it("should display categories list page", () => {
cy.contains("مدیریت دسته‌بندی‌ها").should("be.visible");
cy.contains("مدیریت دسته‌بندی‌های محصولات").should("be.visible");
cy.get('[title="دسته‌بندی جدید"]').should("be.visible");
});
it("should create a new category", () => {
cy.get('[title="دسته‌بندی جدید"]').click();
cy.url().should("include", "/categories/create");
cy.contains("دسته‌بندی جدید").should("be.visible");
// Fill category form
cy.get('input[name="name"]').type("الکترونیک");
cy.get('textarea[name="description"]').type("دسته‌بندی محصولات الکترونیکی");
// Basic category creation without parent selection
cy.get('button[type="submit"]').click();
cy.url().should("include", "/categories");
cy.contains("دسته‌بندی با موفقیت ایجاد شد").should("be.visible");
cy.contains("الکترونیک").should("be.visible");
});
it("should edit a category", () => {
cy.get('[title="ویرایش"]').first().click();
cy.url().should("include", "/categories/");
cy.url().should("include", "/edit");
// Update category
cy.get('input[name="name"]').clear().type("کامپیوتر و لپ‌تاپ");
cy.get('textarea[name="description"]')
.clear()
.type("انواع کامپیوتر و لپ‌تاپ");
cy.get('button[type="submit"]').click();
cy.url().should("include", "/categories");
cy.contains("دسته‌بندی با موفقیت ویرایش شد").should("be.visible");
cy.contains("کامپیوتر و لپ‌تاپ").should("be.visible");
});
it("should delete a category", () => {
cy.get('[title="حذف"]').first().click();
cy.get(".modal").should("be.visible");
cy.contains("آیا از حذف این دسته‌بندی اطمینان دارید؟").should("be.visible");
cy.get("button").contains("حذف").click();
cy.contains("دسته‌بندی با موفقیت حذف شد").should("be.visible");
});
it("should search categories", () => {
cy.get('input[placeholder*="جستجو"]').type("الکترونیک");
cy.get("button").contains("جستجو").click();
cy.waitForLoading();
cy.get("table tbody tr").should("contain", "الکترونیک");
});
it("should display category list", () => {
// Should show categories table
cy.get("table").should("be.visible");
cy.contains("نام دسته‌بندی").should("be.visible");
});
it("should validate category form", () => {
cy.get('[title="دسته‌بندی جدید"]').click();
// Try to submit empty form
cy.get('button[type="submit"]').click();
cy.contains("نام دسته‌بندی الزامی است").should("be.visible");
});
it("should display category status", () => {
// Check if categories show status correctly
cy.get("table tbody tr").should("have.length.at.least", 0);
});
it("should show products count for each category", () => {
cy.get("table tbody tr").each(($row) => {
cy.wrap($row).find(".products-count").should("be.visible");
});
});
it("should handle category with products deletion warning", () => {
// Try to delete category that has products
cy.get('[data-testid="category-with-products"]')
.find('[title="حذف"]')
.click();
cy.get(".modal").should("be.visible");
cy.contains("این دسته‌بندی دارای محصول است").should("be.visible");
cy.contains("ابتدا محصولات را به دسته‌بندی دیگری منتقل کنید").should(
"be.visible"
);
});
it("should bulk delete categories", () => {
// Select multiple categories
cy.get('input[type="checkbox"]').check(["1", "2"]);
cy.get("button").contains("حذف انتخاب شده‌ها").click();
cy.get(".modal").should("be.visible");
cy.get("button").contains("حذف").click();
cy.contains("دسته‌بندی‌های انتخاب شده حذف شدند").should("be.visible");
});
it("should export categories list", () => {
cy.get("button").contains("خروجی").click();
// Should download file
cy.readFile("cypress/downloads/categories.xlsx").should("exist");
});
it("should import categories from file", () => {
cy.get("button").contains("وارد کردن").click();
cy.get('input[type="file"]').selectFile("cypress/fixtures/categories.xlsx");
cy.get("button").contains("آپلود").click();
cy.contains("فایل با موفقیت پردازش شد").should("be.visible");
});
it("should handle category image upload", () => {
cy.get('[title="دسته‌بندی جدید"]').click();
cy.get('input[name="name"]').type("فشن و مد");
// Upload category image
cy.get('input[type="file"]').selectFile(
"cypress/fixtures/category-image.jpg"
);
cy.get(".image-preview").should("be.visible");
cy.get('button[type="submit"]').click();
cy.contains("دسته‌بندی با موفقیت ایجاد شد").should("be.visible");
});
});

View File

@ -0,0 +1,51 @@
describe("Dashboard", () => {
beforeEach(() => {
cy.login();
cy.visit("/");
cy.waitForLoading();
});
it("should display dashboard page with title", () => {
cy.contains("داشبورد").should("be.visible");
});
it("should display statistics cards", () => {
// Check for main metrics based on actual statsData
cy.contains("کل کاربران").should("be.visible");
cy.contains("فروش ماهانه").should("be.visible");
cy.contains("کل سفارشات").should("be.visible");
cy.contains("رشد فروش").should("be.visible");
});
it("should display charts", () => {
// Check if chart section exists
cy.get("body").should("be.visible");
});
it("should show recent users table", () => {
// Check if content area exists
cy.get("main, [role='main'], .content").should("exist");
});
it("should show chart titles", () => {
cy.contains("فروش ماهانه").should("be.visible");
cy.contains("روند رشد").should("be.visible");
cy.contains("دستگاه‌های کاربری").should("be.visible");
});
it("should be responsive on mobile", () => {
cy.viewport("iphone-6");
cy.contains("داشبورد").should("be.visible");
});
it("should display user status badges correctly", () => {
// Check status badges in recent users table
cy.get(".bg-green-100").should("contain", "فعال");
cy.get(".bg-red-100").should("contain", "غیرفعال");
});
it("should show action buttons in table", () => {
// Check if dashboard content loads
cy.get("body").should("contain", "داشبورد");
});
});

View File

@ -0,0 +1,472 @@
describe("Discount Codes Advanced Features", () => {
beforeEach(() => {
cy.login();
cy.visit("/discount-codes");
cy.waitForLoading();
});
describe("Form Validation", () => {
beforeEach(() => {
cy.get('[title="کد تخفیف جدید"]').click();
});
it("should validate code format and uniqueness", () => {
// Test invalid characters (if implemented)
cy.get('input[name="code"]').type("TEST CODE"); // Space in code
cy.get('input[name="name"]').type("Test Name");
cy.get('select[name="type"]').select("percentage");
cy.get('input[name="value"]').type("10");
cy.get('select[name="status"]').select("active");
cy.get('select[name="application_level"]').select("invoice");
// Try to submit - may show validation error for invalid characters
cy.get('button[type="submit"]').click();
// Clear and use valid code
cy.get('input[name="code"]').clear().type("TESTCODE123");
cy.get('button[type="submit"]').click();
cy.url().should("include", "/discount-codes");
});
it("should validate name length constraints", () => {
cy.get('input[name="code"]').type("NAMETEST");
// Test name too long
cy.get('input[name="name"]').type("A".repeat(101));
cy.contains("نام نباید بیشتر از ۱۰۰ کاراکتر باشد").should("be.visible");
// Clear and use valid name
cy.get('input[name="name"]').clear().type("Valid Name");
cy.get('select[name="type"]').select("percentage");
cy.get('input[name="value"]').type("10");
cy.get('select[name="status"]').select("active");
cy.get('select[name="application_level"]').select("invoice");
});
it("should validate description length", () => {
cy.get('input[name="code"]').type("DESCTEST");
cy.get('input[name="name"]').type("Description Test");
// Test description too long
cy.get('textarea[name="description"]').type("A".repeat(501));
cy.contains("توضیحات نباید بیشتر از ۵۰۰ کاراکتر باشد").should(
"be.visible"
);
});
it("should validate percentage values", () => {
cy.get('input[name="code"]').type("PERCENTTEST");
cy.get('input[name="name"]').type("Percent Test");
cy.get('select[name="type"]').select("percentage");
// Test negative value
cy.get('input[name="value"]').type("-10");
cy.contains("مقدار باید بیشتر از صفر باشد").should("be.visible");
// Test zero value
cy.get('input[name="value"]').clear().type("0");
cy.contains("مقدار باید بیشتر از صفر باشد").should("be.visible");
// Test valid value
cy.get('input[name="value"]').clear().type("25");
});
it("should validate usage limits", () => {
cy.get('input[name="code"]').type("USAGETEST");
cy.get('input[name="name"]').type("Usage Test");
cy.get('select[name="type"]').select("percentage");
cy.get('input[name="value"]').type("10");
cy.get('select[name="status"]').select("active");
cy.get('select[name="application_level"]').select("invoice");
// Test invalid usage limit
cy.get('input[name="usage_limit"]').type("0");
cy.contains("حداقل ۱ بار استفاده").should("be.visible");
// Test invalid user usage limit
cy.get('input[name="user_usage_limit"]').type("0");
cy.contains("حداقل ۱ بار استفاده").should("be.visible");
});
it("should validate amount constraints", () => {
cy.get('input[name="code"]').type("AMOUNTTEST");
cy.get('input[name="name"]').type("Amount Test");
cy.get('select[name="type"]').select("fixed");
cy.get('input[name="value"]').type("1000");
cy.get('select[name="status"]').select("active");
cy.get('select[name="application_level"]').select("invoice");
// Test invalid minimum purchase amount
cy.get('input[name="min_purchase_amount"]').type("0");
cy.contains("مبلغ باید بیشتر از صفر باشد").should("be.visible");
// Test invalid maximum discount amount
cy.get('input[name="max_discount_amount"]').type("-100");
cy.contains("مبلغ باید بیشتر از صفر باشد").should("be.visible");
});
});
describe("Date and Time Handling", () => {
beforeEach(() => {
cy.get('[title="کد تخفیف جدید"]').click();
// Fill required fields
cy.get('input[name="code"]').type("DATETEST");
cy.get('input[name="name"]').type("Date Test");
cy.get('select[name="type"]').select("percentage");
cy.get('input[name="value"]').type("10");
cy.get('select[name="status"]').select("active");
cy.get('select[name="application_level"]').select("invoice");
});
it("should handle date range validation", () => {
// Set end date before start date
cy.get('input[name="valid_from"]').type("2024-12-31T23:59");
cy.get('input[name="valid_to"]').type("2024-01-01T00:00");
// Form should still accept it (backend validation)
cy.get('button[type="submit"]').click();
cy.url().should("include", "/discount-codes");
});
it("should preserve datetime values in edit mode", () => {
// Set specific datetime values
const fromDate = "2024-06-01T10:30";
const toDate = "2024-06-30T18:45";
cy.get('input[name="valid_from"]').type(fromDate);
cy.get('input[name="valid_to"]').type(toDate);
cy.get('button[type="submit"]').click();
cy.url().should("include", "/discount-codes");
// Edit the created discount code
cy.contains("DATETEST")
.parent()
.parent()
.within(() => {
cy.get('[title="ویرایش"]').click();
});
// Values should be preserved
cy.get('input[name="valid_from"]').should("have.value", fromDate);
cy.get('input[name="valid_to"]').should("have.value", toDate);
});
});
describe("User Restrictions", () => {
beforeEach(() => {
cy.get('[title="کد تخفیف جدید"]').click();
// Fill required fields
cy.get('input[name="code"]').type("USERTEST");
cy.get('input[name="name"]').type("User Test");
cy.get('select[name="type"]').select("percentage");
cy.get('input[name="value"]').type("15");
cy.get('select[name="status"]').select("active");
cy.get('select[name="application_level"]').select("invoice");
});
it("should handle user group selection", () => {
// Test all user group options
cy.get('select[name="user_restrictions.user_group"]').select("new");
cy.get('select[name="user_restrictions.user_group"]').should(
"have.value",
"new"
);
cy.get('select[name="user_restrictions.user_group"]').select("loyal");
cy.get('select[name="user_restrictions.user_group"]').should(
"have.value",
"loyal"
);
cy.get('select[name="user_restrictions.user_group"]').select("all");
cy.get('select[name="user_restrictions.user_group"]').should(
"have.value",
"all"
);
});
it("should handle purchase count restrictions", () => {
cy.get('input[name="user_restrictions.min_purchase_count"]').type("2");
cy.get('input[name="user_restrictions.max_purchase_count"]').type("10");
cy.get('input[name="user_restrictions.referrer_user_id"]').type("456");
cy.get('button[type="submit"]').click();
cy.url().should("include", "/discount-codes");
cy.contains("کد تخفیف با موفقیت ایجاد شد").should("be.visible");
});
it("should warn about conflicting user restrictions", () => {
// Check both new users only and loyal users only
cy.get('input[name="user_restrictions.new_users_only"]').check();
cy.get('input[name="user_restrictions.loyal_users_only"]').check();
// Warning should be visible
cy.contains(
"new_users_only و loyal_users_only نمی‌توانند همزمان فعال باشند"
).should("be.visible");
// Uncheck one
cy.get('input[name="user_restrictions.new_users_only"]').uncheck();
cy.get('button[type="submit"]').click();
cy.url().should("include", "/discount-codes");
});
});
describe("Application Levels", () => {
beforeEach(() => {
cy.get('[title="کد تخفیف جدید"]').click();
cy.get('input[name="code"]').type("APPTEST");
cy.get('input[name="name"]').type("Application Test");
cy.get('input[name="value"]').type("100");
cy.get('select[name="status"]').select("active");
});
it("should handle product fee application with fee percentage type", () => {
cy.get('select[name="type"]').select("fee_percentage");
cy.get('select[name="application_level"]').select("product_fee");
cy.get('button[type="submit"]').click();
cy.url().should("include", "/discount-codes");
cy.contains("کد تخفیف با موفقیت ایجاد شد").should("be.visible");
});
it("should test all application level combinations", () => {
const types = ["percentage", "fixed", "fee_percentage"];
const applications = [
"invoice",
"category",
"product",
"shipping",
"product_fee",
];
types.forEach((type, typeIndex) => {
applications.forEach((app, appIndex) => {
if (typeIndex > 0 || appIndex > 0) {
// Generate unique code for each combination
cy.get('input[name="code"]')
.clear()
.type(`TEST${typeIndex}${appIndex}`);
}
cy.get('select[name="type"]').select(type);
cy.get('select[name="application_level"]').select(app);
// For fee_percentage, use smaller values
if (type === "fee_percentage") {
cy.get('input[name="value"]').clear().type("5");
} else if (type === "percentage") {
cy.get('input[name="value"]').clear().type("10");
} else {
cy.get('input[name="value"]').clear().type("1000");
}
cy.get('button[type="submit"]').click();
cy.url().should("include", "/discount-codes");
// Go back to create page for next iteration (except last)
if (
!(
typeIndex === types.length - 1 &&
appIndex === applications.length - 1
)
) {
cy.get('[title="کد تخفیف جدید"]').click();
cy.get('input[name="name"]').type("Application Test");
cy.get('select[name="status"]').select("active");
}
});
});
});
});
describe("Meta Information", () => {
it("should handle meta fields properly", () => {
cy.get('[title="کد تخفیف جدید"]').click();
cy.get('input[name="code"]').type("METATEST");
cy.get('input[name="name"]').type("Meta Test");
cy.get('select[name="type"]').select("percentage");
cy.get('input[name="value"]').type("20");
cy.get('select[name="status"]').select("active");
cy.get('select[name="application_level"]').select("invoice");
// Set meta fields
cy.get('input[name="meta.campaign"]').type("winter_sale_2024");
cy.get('input[name="meta.category"]').type("seasonal_promotion");
cy.get('button[type="submit"]').click();
cy.url().should("include", "/discount-codes");
cy.contains("کد تخفیف با موفقیت ایجاد شد").should("be.visible");
// Verify meta fields are preserved in edit
cy.contains("METATEST")
.parent()
.parent()
.within(() => {
cy.get('[title="ویرایش"]').click();
});
cy.get('input[name="meta.campaign"]').should(
"have.value",
"winter_sale_2024"
);
cy.get('input[name="meta.category"]').should(
"have.value",
"seasonal_promotion"
);
});
});
describe("List Page Features", () => {
it("should display correct value format based on type", () => {
// Create different types of discounts to test display
const testCodes = [
{ code: "DISPLAYPERCENT", type: "percentage", value: "25" },
{ code: "DISPLAYFIXED", type: "fixed", value: "50000" },
{ code: "DISPLAYFEE", type: "fee_percentage", value: "5" },
];
testCodes.forEach((testCode) => {
cy.get('[title="کد تخفیف جدید"]').click();
cy.get('input[name="code"]').type(testCode.code);
cy.get('input[name="name"]').type(`Display Test ${testCode.type}`);
cy.get('select[name="type"]').select(testCode.type);
cy.get('input[name="value"]').type(testCode.value);
cy.get('select[name="status"]').select("active");
cy.get('select[name="application_level"]').select(
testCode.type === "fee_percentage" ? "product_fee" : "invoice"
);
cy.get('button[type="submit"]').click();
cy.url().should("include", "/discount-codes");
});
// Check display formats
cy.contains("DISPLAYPERCENT")
.parent()
.parent()
.within(() => {
cy.contains("25%").should("be.visible");
});
cy.contains("DISPLAYFIXED")
.parent()
.parent()
.within(() => {
cy.contains("50000 تومان").should("be.visible");
});
cy.contains("DISPLAYFEE")
.parent()
.parent()
.within(() => {
cy.contains("5%").should("be.visible");
});
});
it("should handle pagination properly", () => {
// This test assumes there are enough items to paginate
// Check if pagination exists
cy.get('nav[aria-label="Pagination Navigation"]').should("exist");
// Test pagination controls if they exist
cy.get('nav[aria-label="Pagination Navigation"]').within(() => {
cy.get("button").should("have.length.greaterThan", 0);
});
});
it("should sort columns when sortable", () => {
// Click on sortable column headers
cy.get("th").contains("کد").click();
cy.wait(500);
cy.get("th").contains("نام").click();
cy.wait(500);
// Verify table content changes (basic check)
cy.get("table tbody tr").should("have.length.greaterThan", 0);
});
});
describe("Error Handling", () => {
it("should handle network errors gracefully", () => {
// Intercept network requests and simulate errors
cy.intercept("POST", "**/discount/", { statusCode: 500 }).as(
"createError"
);
cy.get('[title="کد تخفیف جدید"]').click();
cy.get('input[name="code"]').type("ERRORTEST");
cy.get('input[name="name"]').type("Error Test");
cy.get('select[name="type"]').select("percentage");
cy.get('input[name="value"]').type("10");
cy.get('select[name="status"]').select("active");
cy.get('select[name="application_level"]').select("invoice");
cy.get('button[type="submit"]').click();
cy.wait("@createError");
cy.contains("خطا در ایجاد کد تخفیف").should("be.visible");
});
it("should handle loading states", () => {
cy.get('[title="کد تخفیف جدید"]').click();
// Intercept with delay to see loading state
cy.intercept("POST", "**/discount/", { delay: 2000, statusCode: 200 }).as(
"createSlow"
);
cy.get('input[name="code"]').type("LOADTEST");
cy.get('input[name="name"]').type("Load Test");
cy.get('select[name="type"]').select("percentage");
cy.get('input[name="value"]').type("10");
cy.get('select[name="status"]').select("active");
cy.get('select[name="application_level"]').select("invoice");
cy.get('button[type="submit"]').click();
// Check loading state
cy.get('button[type="submit"]').should("be.disabled");
cy.get(".animate-spin").should("be.visible");
cy.wait("@createSlow");
});
});
describe("Responsive Design", () => {
it("should work on mobile viewport", () => {
cy.viewport("iphone-6");
cy.get('[title="کد تخفیف جدید"]').should("be.visible");
cy.get('[title="کد تخفیف جدید"]').click();
cy.contains("ایجاد کد تخفیف").should("be.visible");
// Form should be usable on mobile
cy.get('input[name="code"]').type("MOBILETEST");
cy.get('input[name="name"]').type("Mobile Test");
cy.get('select[name="type"]').select("percentage");
cy.get('input[name="value"]').type("10");
cy.get('select[name="status"]').select("active");
cy.get('select[name="application_level"]').select("invoice");
cy.get('button[type="submit"]').should("be.visible").click();
cy.url().should("include", "/discount-codes");
});
it("should work on tablet viewport", () => {
cy.viewport("ipad-2");
cy.get('[title="کد تخفیف جدید"]').should("be.visible");
cy.get("table").should("be.visible");
// Test form on tablet
cy.get('[title="کد تخفیف جدید"]').click();
cy.get(".grid").should("be.visible"); // Grid layout should work
});
});
});

View File

@ -0,0 +1,373 @@
import { discountTemplates, apiMocks } from "../support/discount-codes-helpers";
describe("Discount Codes - Complete E2E Tests", () => {
beforeEach(() => {
cy.login();
});
describe("Navigation and Basic UI", () => {
it("should display discount codes list page correctly", () => {
cy.visit("/discount-codes");
cy.waitForLoading();
cy.contains("مدیریت کدهای تخفیف").should("be.visible");
cy.get('[title="کد تخفیف جدید"]').should("be.visible");
});
it("should navigate to create page", () => {
cy.navigateToCreateDiscount();
cy.contains("ایجاد کد تخفیف").should("be.visible");
});
});
describe("Form Validation", () => {
beforeEach(() => {
cy.navigateToCreateDiscount();
});
it("should validate required fields", () => {
cy.getByTestId("submit-discount-button").should("be.disabled");
cy.fillBasicDiscountInfo({
code: "TEST123",
name: "Test Discount",
});
cy.getByTestId("submit-discount-button").should("be.disabled");
cy.fillDiscountSettings({
type: "percentage",
value: "10",
status: "active",
applicationLevel: "invoice",
});
cy.getByTestId("submit-discount-button").should("not.be.disabled");
});
it("should validate field lengths and formats", () => {
// Test code length
cy.getByTestId("discount-code-input").type("AB");
cy.getByTestId("discount-name-input").type("Test");
cy.get(".text-red-600").should("contain", "کد باید حداقل ۳ کاراکتر باشد");
// Test code too long
cy.getByTestId("discount-code-input").clear().type("A".repeat(51));
cy.get(".text-red-600").should(
"contain",
"کد نباید بیشتر از ۵۰ کاراکتر باشد"
);
// Test name too long
cy.getByTestId("discount-name-input").clear().type("A".repeat(101));
cy.get(".text-red-600").should(
"contain",
"نام نباید بیشتر از ۱۰۰ کاراکتر باشد"
);
// Test description too long
cy.getByTestId("discount-description-textarea").type("A".repeat(501));
cy.get(".text-red-600").should(
"contain",
"توضیحات نباید بیشتر از ۵۰۰ کاراکتر باشد"
);
});
it("should validate numeric fields", () => {
cy.fillBasicDiscountInfo({
code: "NUMTEST",
name: "Number Test",
});
// Test negative value
cy.getByTestId("discount-value-input").type("-10");
cy.get(".text-red-600").should("contain", "مقدار باید بیشتر از صفر باشد");
// Test zero value
cy.getByTestId("discount-value-input").clear().type("0");
cy.get(".text-red-600").should("contain", "مقدار باید بیشتر از صفر باشد");
});
});
describe("Discount Creation", () => {
beforeEach(() => {
// Mock successful API responses
cy.intercept("GET", "**/discount/**", apiMocks.discountsList).as(
"getDiscounts"
);
cy.intercept("POST", "**/discount/**", (req) => {
return apiMocks.successfulCreation(req.body);
}).as("createDiscount");
});
it("should create basic percentage discount", () => {
cy.createDiscountCode(discountTemplates.basicPercentage);
cy.wait("@createDiscount");
cy.contains("کد تخفیف با موفقیت ایجاد شد").should("be.visible");
});
it("should create fixed amount discount", () => {
cy.createDiscountCode(discountTemplates.fixedAmount);
cy.wait("@createDiscount");
cy.contains("کد تخفیف با موفقیت ایجاد شد").should("be.visible");
});
it("should create fee percentage discount", () => {
cy.createDiscountCode(discountTemplates.feePercentage);
cy.wait("@createDiscount");
cy.contains("کد تخفیف با موفقیت ایجاد شد").should("be.visible");
});
it("should create discount with user restrictions", () => {
cy.createDiscountCode(discountTemplates.loyalUsers);
cy.wait("@createDiscount");
cy.contains("کد تخفیف با موفقیت ایجاد شد").should("be.visible");
});
it("should create time-based discount with all features", () => {
cy.createDiscountCode(discountTemplates.timeBasedDiscount);
cy.wait("@createDiscount");
cy.contains("کد تخفیف با موفقیت ایجاد شد").should("be.visible");
});
});
describe("Error Handling", () => {
it("should handle validation errors from API", () => {
cy.intercept("POST", "**/discount/**", apiMocks.validationError).as(
"validationError"
);
cy.navigateToCreateDiscount();
cy.fillBasicDiscountInfo(discountTemplates.basicPercentage);
cy.fillDiscountSettings(discountTemplates.basicPercentage);
cy.submitDiscountForm();
cy.wait("@validationError");
cy.contains("کد تخفیف تکراری است").should("be.visible");
});
it("should handle server errors", () => {
cy.intercept("POST", "**/discount/**", apiMocks.serverError).as(
"serverError"
);
cy.navigateToCreateDiscount();
cy.fillBasicDiscountInfo(discountTemplates.basicPercentage);
cy.fillDiscountSettings(discountTemplates.basicPercentage);
cy.submitDiscountForm();
cy.wait("@serverError");
cy.contains("خطا در ایجاد کد تخفیف").should("be.visible");
});
it("should handle loading states", () => {
cy.intercept("POST", "**/discount/**", {
delay: 2000,
...apiMocks.successfulCreation(discountTemplates.basicPercentage),
}).as("slowCreate");
cy.navigateToCreateDiscount();
cy.fillBasicDiscountInfo(discountTemplates.basicPercentage);
cy.fillDiscountSettings(discountTemplates.basicPercentage);
cy.submitDiscountForm();
// Check loading state
cy.getByTestId("submit-discount-button").should("be.disabled");
cy.get(".animate-spin").should("be.visible");
cy.wait("@slowCreate");
});
});
describe("List Page Features", () => {
beforeEach(() => {
cy.intercept("GET", "**/discount/**", apiMocks.discountsList).as(
"getDiscounts"
);
cy.visit("/discount-codes");
cy.wait("@getDiscounts");
});
it("should search discount codes", () => {
cy.searchDiscountCode("SAVE20");
cy.contains("SAVE20").should("be.visible");
cy.searchDiscountCode("NONEXISTENT");
cy.contains("هیچ کد تخفیفی یافت نشد").should("be.visible");
});
it("should clear filters", () => {
cy.searchDiscountCode("TEST");
cy.clearDiscountFilters();
cy.get('input[placeholder*="جستجو"]').should("have.value", "");
});
it("should display discount codes with correct formatting", () => {
cy.contains("SAVE20").should("be.visible");
cy.contains("20%").should("be.visible");
cy.get(".bg-green-100").should("contain", "فعال");
});
});
describe("Edit Functionality", () => {
beforeEach(() => {
cy.intercept("GET", "**/discount/**", apiMocks.discountsList).as(
"getDiscounts"
);
cy.intercept("GET", "**/discount/1", {
statusCode: 200,
body: {
id: 1,
code: "SAVE20",
name: "20% Off Discount",
description: "Get 20% off on your purchase",
type: "percentage",
value: 20,
status: "active",
application_level: "invoice",
},
}).as("getDiscount");
cy.intercept("PUT", "**/discount/1", {
statusCode: 200,
body: { message: "updated successfully" },
}).as("updateDiscount");
});
it("should edit existing discount code", () => {
cy.visit("/discount-codes");
cy.wait("@getDiscounts");
cy.contains("SAVE20")
.parent()
.parent()
.within(() => {
cy.get('[title="ویرایش"]').click();
});
cy.wait("@getDiscount");
cy.url().should("include", "/edit");
cy.getByTestId("discount-name-input")
.clear()
.type("Updated Discount Name");
cy.submitDiscountForm();
cy.wait("@updateDiscount");
cy.contains("کد تخفیف با موفقیت به‌روزرسانی شد").should("be.visible");
});
});
describe("Delete Functionality", () => {
beforeEach(() => {
cy.intercept("GET", "**/discount/**", apiMocks.discountsList).as(
"getDiscounts"
);
cy.intercept("DELETE", "**/discount/**", {
statusCode: 200,
body: { message: "deleted successfully" },
}).as("deleteDiscount");
});
it("should delete discount code", () => {
cy.visit("/discount-codes");
cy.wait("@getDiscounts");
cy.contains("SAVE20")
.parent()
.parent()
.within(() => {
cy.get('[title="حذف"]').click();
});
cy.contains("آیا از حذف این کد تخفیف اطمینان دارید؟").should(
"be.visible"
);
cy.contains("button", "حذف").click();
cy.wait("@deleteDiscount");
cy.contains("کد تخفیف با موفقیت حذف شد").should("be.visible");
});
});
describe("Responsive Design", () => {
it("should work on mobile devices", () => {
cy.viewport("iphone-6");
cy.navigateToCreateDiscount();
cy.fillBasicDiscountInfo({
code: "MOBILE123",
name: "Mobile Test",
});
cy.fillDiscountSettings({
type: "percentage",
value: "10",
status: "active",
applicationLevel: "invoice",
});
cy.getByTestId("submit-discount-button").should("be.visible");
});
it("should work on tablets", () => {
cy.viewport("ipad-2");
cy.visit("/discount-codes");
cy.waitForLoading();
cy.get("table").should("be.visible");
cy.get('[title="کد تخفیف جدید"]').should("be.visible");
});
});
describe("Accessibility", () => {
it("should be keyboard navigable", () => {
cy.navigateToCreateDiscount();
cy.getByTestId("discount-code-input").focus();
cy.focused().should("have.attr", "data-testid", "discount-code-input");
cy.focused().tab();
cy.focused().should("have.attr", "data-testid", "discount-name-input");
});
it("should have proper ARIA labels", () => {
cy.navigateToCreateDiscount();
cy.get("label").should("have.length.greaterThan", 5);
cy.get("input[required]").should("have.length.greaterThan", 3);
});
it("should announce errors to screen readers", () => {
cy.navigateToCreateDiscount();
cy.getByTestId("discount-code-input").type("AB");
cy.get(".text-red-600").should("have.attr", "role", "alert");
});
});
describe("Performance", () => {
it("should load create page quickly", () => {
const startTime = Date.now();
cy.navigateToCreateDiscount();
cy.getByTestId("discount-code-input")
.should("be.visible")
.then(() => {
const loadTime = Date.now() - startTime;
expect(loadTime).to.be.lessThan(3000); // Should load within 3 seconds
});
});
it("should handle large forms efficiently", () => {
cy.navigateToCreateDiscount();
// Fill form quickly without delays
cy.getByTestId("discount-code-input").type("PERF123");
cy.getByTestId("discount-name-input").type("Performance Test");
cy.getByTestId("discount-description-textarea").type("A".repeat(400));
cy.getByTestId("discount-type-select").select("percentage");
cy.getByTestId("discount-value-input").type("25");
// Form should remain responsive
cy.getByTestId("submit-discount-button").should("not.be.disabled");
});
});
});

View File

@ -0,0 +1,251 @@
/// <reference types="../support" />
describe("Discount Codes Management - Fixed", () => {
beforeEach(() => {
cy.login();
cy.visit("/discount-codes");
cy.waitForLoading();
});
it("should display discount codes list page", () => {
cy.contains("مدیریت کدهای تخفیف").should("be.visible");
cy.getByTestId("create-discount-button").should("be.visible");
});
it("should navigate to create discount code page", () => {
cy.getByTestId("create-discount-button").click();
cy.url().should("include", "/discount-codes/create");
cy.contains("ایجاد کد تخفیف").should("be.visible");
});
it("should create a basic percentage discount code", () => {
cy.getByTestId("create-discount-button").click();
// Fill basic information using data-testid
cy.getByTestId("discount-code-input").type("SAVE20");
cy.getByTestId("discount-name-input").type("تخفیف ۲۰ درصدی");
cy.getByTestId("discount-description-textarea").type(
"تخفیف ۲۰ درصدی برای کل خرید"
);
// Set discount settings using data-testid
cy.getByTestId("discount-type-select").select("percentage");
cy.getByTestId("discount-value-input").type("20");
// Set other required fields
cy.getByTestId("discount-status-select").select("active");
cy.getByTestId("discount-application-level-select").select("invoice");
// Submit form
cy.getByTestId("submit-discount-button").click();
// Verify creation (might need to mock API response)
cy.url().should("include", "/discount-codes");
});
it("should validate required fields properly", () => {
cy.getByTestId("create-discount-button").click();
// Submit button should be disabled initially
cy.getByTestId("submit-discount-button").should("be.disabled");
// Fill only code field
cy.getByTestId("discount-code-input").type("TEST");
cy.getByTestId("submit-discount-button").should("be.disabled");
// Fill name field
cy.getByTestId("discount-name-input").type("Test Name");
cy.getByTestId("submit-discount-button").should("be.disabled");
// Fill all required fields
cy.getByTestId("discount-type-select").select("percentage");
cy.getByTestId("discount-value-input").type("10");
cy.getByTestId("discount-status-select").select("active");
cy.getByTestId("discount-application-level-select").select("invoice");
// Now submit button should be enabled
cy.getByTestId("submit-discount-button").should("not.be.disabled");
});
it("should validate code length constraints", () => {
cy.getByTestId("create-discount-button").click();
// Test code too short
cy.getByTestId("discount-code-input").type("AB");
cy.getByTestId("discount-name-input").type("Test");
cy.getByTestId("discount-type-select").select("percentage");
cy.getByTestId("discount-value-input").type("10");
// Check for validation error
cy.get(".text-red-600").should("contain", "کد باید حداقل ۳ کاراکتر باشد");
// Clear and test code too long
cy.getByTestId("discount-code-input").clear().type("A".repeat(51));
cy.get(".text-red-600").should(
"contain",
"کد نباید بیشتر از ۵۰ کاراکتر باشد"
);
});
it("should create different discount types", () => {
const discountTypes = [
{ type: "percentage", value: "25", level: "invoice" },
{ type: "fixed", value: "50000", level: "invoice" },
{ type: "fee_percentage", value: "5", level: "product_fee" },
];
discountTypes.forEach((discount, index) => {
// Navigate to create page before each iteration
cy.visit("/discount-codes");
cy.waitForLoading();
cy.getByTestId("create-discount-button").click();
cy.getByTestId("discount-code-input").type(
`TEST${index}${discount.type.toUpperCase()}`
);
cy.getByTestId("discount-name-input").type(`Test ${discount.type}`);
cy.getByTestId("discount-type-select").select(discount.type);
cy.getByTestId("discount-value-input").type(discount.value);
cy.getByTestId("discount-status-select").select("active");
cy.getByTestId("discount-application-level-select").select(
discount.level
);
cy.getByTestId("submit-discount-button").click();
cy.url().should("include", "/discount-codes");
});
});
it("should handle form cancellation", () => {
cy.getByTestId("create-discount-button").click();
// Fill some data
cy.getByTestId("discount-code-input").type("CANCELTEST");
cy.getByTestId("discount-name-input").type("Cancel Test");
// Click cancel button
cy.getByTestId("cancel-discount-button").click();
// Should return to list page
cy.url().should("include", "/discount-codes");
cy.url().should("not.include", "/create");
});
it("should show empty state when no results found", () => {
// Search for non-existent code
cy.get('input[placeholder*="جستجو"]').type("NONEXISTENTCODE123");
cy.wait(500);
// Check for empty state
cy.contains("هیچ کد تخفیفی یافت نشد").should("be.visible");
});
it("should navigate back properly", () => {
cy.getByTestId("create-discount-button").click();
// Wait for form to load completely
cy.getByTestId("discount-code-input").should("be.visible");
// Click cancel button
cy.getByTestId("cancel-discount-button").click();
// Should return to list page
cy.url().should("include", "/discount-codes");
cy.url().should("not.include", "/create");
});
// Test with API mocking
it("should handle API errors gracefully", () => {
// Mock API error
cy.intercept("POST", "**/discount/**", {
statusCode: 400,
body: { message: "کد تخفیف تکراری است" },
}).as("createError");
cy.getByTestId("create-discount-button").click();
cy.getByTestId("discount-code-input").type("ERRORTEST");
cy.getByTestId("discount-name-input").type("Error Test");
cy.getByTestId("discount-type-select").select("percentage");
cy.getByTestId("discount-value-input").type("10");
cy.getByTestId("discount-status-select").select("active");
cy.getByTestId("discount-application-level-select").select("invoice");
cy.getByTestId("submit-discount-button").click();
cy.wait("@createError");
// Error message should appear
cy.contains("خطا در ایجاد کد تخفیف").should("be.visible");
});
it("should handle loading states", () => {
// Mock slow API response
cy.intercept("POST", "**/discount/**", {
delay: 2000,
statusCode: 201,
body: { id: 1, code: "TEST", name: "Test" },
}).as("createSlow");
cy.getByTestId("create-discount-button").click();
cy.getByTestId("discount-code-input").type("LOADTEST");
cy.getByTestId("discount-name-input").type("Load Test");
cy.getByTestId("discount-type-select").select("percentage");
cy.getByTestId("discount-value-input").type("10");
cy.getByTestId("discount-status-select").select("active");
cy.getByTestId("discount-application-level-select").select("invoice");
cy.getByTestId("submit-discount-button").click();
// Check loading state
cy.getByTestId("submit-discount-button").should("be.disabled");
cy.wait("@createSlow");
});
// Test mobile responsiveness
it("should work on mobile viewport", () => {
cy.viewport("iphone-6");
cy.getByTestId("create-discount-button").should("be.visible");
cy.getByTestId("create-discount-button").click();
cy.contains("ایجاد کد تخفیف").should("be.visible");
// Form should be usable on mobile
cy.getByTestId("discount-code-input").type("MOBILETEST");
cy.getByTestId("discount-name-input").type("Mobile Test");
cy.getByTestId("discount-type-select").select("percentage");
cy.getByTestId("discount-value-input").type("10");
cy.getByTestId("discount-status-select").select("active");
cy.getByTestId("discount-application-level-select").select("invoice");
// Scroll to submit button to make it visible
cy.getByTestId("submit-discount-button").scrollIntoView();
cy.getByTestId("submit-discount-button").should("be.visible");
});
// Test accessibility
it("should be accessible", () => {
cy.getByTestId("create-discount-button").click();
// Check for proper labels
cy.get("label").should("have.length.greaterThan", 5);
// Check for required field indicators
cy.getByTestId("discount-code-input").should(
"have.attr",
"aria-required",
"true"
);
cy.getByTestId("discount-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", 3);
});
});

View File

@ -0,0 +1,331 @@
describe("Discount Codes Management", () => {
beforeEach(() => {
cy.login();
cy.visit("/discount-codes");
cy.waitForLoading();
});
it("should display discount codes list page", () => {
cy.contains("مدیریت کدهای تخفیف").should("be.visible");
cy.contains("ایجاد و مدیریت کدهای تخفیف").should("be.visible");
cy.get('[title="کد تخفیف جدید"]').should("be.visible");
});
it("should navigate to create discount code page", () => {
cy.get('[title="کد تخفیف جدید"]').click();
cy.url().should("include", "/discount-codes/create");
cy.contains("ایجاد کد تخفیف").should("be.visible");
cy.contains("ایجاد و مدیریت کدهای تخفیف برای فروشگاه").should("be.visible");
});
it("should create a percentage discount code", () => {
cy.get('[title="کد تخفیف جدید"]').click();
// Fill basic information
cy.get('input[name="code"]').type("SAVE20");
cy.get('input[name="name"]').type("تخفیف ۲۰ درصدی");
cy.get('textarea[name="description"]').type("تخفیف ۲۰ درصدی برای کل خرید");
// Set discount settings
cy.get('select[name="type"]').select("percentage");
cy.get('input[name="value"]').type("20");
cy.get('select[name="status"]').select("active");
cy.get('select[name="application_level"]').select("invoice");
// Set limits
cy.get('input[name="min_purchase_amount"]').type("100000");
cy.get('input[name="max_discount_amount"]').type("50000");
cy.get('input[name="usage_limit"]').type("1000");
cy.get('input[name="user_usage_limit"]').type("1");
// Set date range
cy.get('input[name="valid_from"]').type("2024-01-01T00:00");
cy.get('input[name="valid_to"]').type("2024-12-31T23:59");
// Set user restrictions
cy.get('select[name="user_restrictions.user_group"]').select("loyal");
// Set meta information
cy.get('input[name="meta.campaign"]').type("summer_sale");
cy.get('input[name="meta.category"]').type("general");
// Submit form
cy.get('button[type="submit"]').click();
// Verify creation
cy.url().should("include", "/discount-codes");
cy.contains("کد تخفیف با موفقیت ایجاد شد").should("be.visible");
cy.contains("SAVE20").should("be.visible");
});
it("should create a fixed amount discount code", () => {
cy.get('[title="کد تخفیف جدید"]').click();
// Fill basic information
cy.get('input[name="code"]').type("FIXED50000");
cy.get('input[name="name"]').type("تخفیف ۵۰ هزار تومانی");
cy.get('textarea[name="description"]').type(
"تخفیف مبلغ ثابت ۵۰ هزار تومان"
);
// Set discount settings
cy.get('select[name="type"]').select("fixed");
cy.get('input[name="value"]').type("50000");
cy.get('select[name="status"]').select("active");
cy.get('select[name="application_level"]').select("invoice");
// Set single use
cy.get('input[name="single_use"]').check();
// Set user restrictions for new users only
cy.get('input[name="user_restrictions.new_users_only"]').check();
// Submit form
cy.get('button[type="submit"]').click();
// Verify creation
cy.url().should("include", "/discount-codes");
cy.contains("کد تخفیف با موفقیت ایجاد شد").should("be.visible");
cy.contains("FIXED50000").should("be.visible");
});
it("should create a fee percentage discount code", () => {
cy.get('[title="کد تخفیف جدید"]').click();
// Fill basic information
cy.get('input[name="code"]').type("FEEREDUCTION10");
cy.get('input[name="name"]').type("کاهش کارمزد ۱۰ درصدی");
// Set discount settings
cy.get('select[name="type"]').select("fee_percentage");
cy.get('input[name="value"]').type("10");
cy.get('select[name="application_level"]').select("product_fee");
// Submit form
cy.get('button[type="submit"]').click();
// Verify creation
cy.url().should("include", "/discount-codes");
cy.contains("کد تخفیف با موفقیت ایجاد شد").should("be.visible");
cy.contains("FEEREDUCTION10").should("be.visible");
});
it("should validate required fields", () => {
cy.get('[title="کد تخفیف جدید"]').click();
// Try to submit without required fields
cy.get('button[type="submit"]').should("be.disabled");
// Fill only code field
cy.get('input[name="code"]').type("TEST");
cy.get('button[type="submit"]').should("be.disabled");
// Fill name field
cy.get('input[name="name"]').type("Test");
cy.get('button[type="submit"]').should("be.disabled");
// Fill all required fields
cy.get('select[name="type"]').select("percentage");
cy.get('input[name="value"]').type("10");
cy.get('select[name="status"]').select("active");
cy.get('select[name="application_level"]').select("invoice");
// Now submit button should be enabled
cy.get('button[type="submit"]').should("not.be.disabled");
});
it("should validate code length", () => {
cy.get('[title="کد تخفیف جدید"]').click();
// Test code too short
cy.get('input[name="code"]').type("AB");
cy.get('input[name="name"]').type("Test");
cy.get('select[name="type"]').select("percentage");
cy.get('input[name="value"]').type("10");
cy.contains("کد باید حداقل ۳ کاراکتر باشد").should("be.visible");
// Clear and test code too long
cy.get('input[name="code"]').clear().type("A".repeat(51));
cy.contains("کد نباید بیشتر از ۵۰ کاراکتر باشد").should("be.visible");
});
it("should validate percentage value range", () => {
cy.get('[title="کد تخفیف جدید"]').click();
cy.get('input[name="code"]').type("TESTPERCENTAGE");
cy.get('input[name="name"]').type("Test Percentage");
cy.get('select[name="type"]').select("percentage");
// Test value too high for percentage (should warn in UI for >100)
cy.get('input[name="value"]').type("150");
// The form should still accept it but backend will validate
cy.get('select[name="status"]').select("active");
cy.get('select[name="application_level"]').select("invoice");
});
it("should search and filter discount codes", () => {
// Assuming we have some discount codes in the list
cy.get('input[placeholder="جستجو بر اساس کد..."]').type("SAVE");
cy.wait(500); // Wait for search to filter
// Test status filter
cy.get("select").contains("همه وضعیت‌ها").parent().select("active");
cy.wait(500);
// Clear filters
cy.contains("پاک کردن فیلترها").click();
cy.get('input[placeholder="جستجو بر اساس کد..."]').should("have.value", "");
});
it("should edit existing discount code", () => {
// Assuming we have discount codes in the list
cy.get("table tbody tr")
.first()
.within(() => {
cy.get('[title="ویرایش"]').click();
});
cy.url().should("include", "/discount-codes/");
cy.url().should("include", "/edit");
cy.contains("ویرایش کد تخفیف").should("be.visible");
// Modify the discount code
cy.get('input[name="name"]').clear().type("کد تخفیف ویرایش شده");
cy.get('textarea[name="description"]').clear().type("توضیحات ویرایش شده");
// Submit changes
cy.get('button[type="submit"]').click();
// Verify update
cy.url().should("include", "/discount-codes");
cy.contains("کد تخفیف با موفقیت به‌روزرسانی شد").should("be.visible");
cy.contains("کد تخفیف ویرایش شده").should("be.visible");
});
it("should delete discount code", () => {
// Create a test discount code first
cy.get('[title="کد تخفیف جدید"]').click();
cy.get('input[name="code"]').type("TESTDELETE");
cy.get('input[name="name"]').type("Test Delete");
cy.get('select[name="type"]').select("percentage");
cy.get('input[name="value"]').type("10");
cy.get('select[name="status"]').select("active");
cy.get('select[name="application_level"]').select("invoice");
cy.get('button[type="submit"]').click();
// Wait for creation
cy.contains("کد تخفیف با موفقیت ایجاد شد").should("be.visible");
// Find and delete the test discount code
cy.contains("TESTDELETE")
.parent()
.parent()
.within(() => {
cy.get('[title="حذف"]').click();
});
// Confirm deletion in modal
cy.contains("آیا از حذف این کد تخفیف اطمینان دارید؟").should("be.visible");
cy.contains("button", "حذف").click();
// Verify deletion
cy.contains("کد تخفیف با موفقیت حذف شد").should("be.visible");
cy.contains("TESTDELETE").should("not.exist");
});
it("should display proper status badges", () => {
// Check if status badges are displayed with correct colors
cy.get("table tbody tr").each(($row) => {
cy.wrap($row).within(() => {
cy.get("span")
.contains(/فعال|غیرفعال/)
.should("exist");
});
});
});
it("should show empty state when no discount codes exist", () => {
// This test assumes a clean state or uses a filter that returns no results
cy.get('input[placeholder="جستجو بر اساس کد..."]').type("NONEXISTENTCODE");
cy.wait(500);
cy.contains("هیچ کد تخفیفی یافت نشد").should("be.visible");
cy.contains("برای شروع یک کد تخفیف ایجاد کنید").should("be.visible");
});
it("should cancel discount code creation", () => {
cy.get('[title="کد تخفیف جدید"]').click();
// Fill some data
cy.get('input[name="code"]').type("CANCELTEST");
cy.get('input[name="name"]').type("Cancel Test");
// Click cancel
cy.contains("button", "انصراف").click();
// Should return to list page
cy.url().should("include", "/discount-codes");
cy.url().should("not.include", "/create");
});
it("should handle user restrictions properly", () => {
cy.get('[title="کد تخفیف جدید"]').click();
// Fill basic fields
cy.get('input[name="code"]').type("USERRESTRICT");
cy.get('input[name="name"]').type("User Restriction Test");
cy.get('select[name="type"]').select("percentage");
cy.get('input[name="value"]').type("15");
cy.get('select[name="status"]').select("active");
cy.get('select[name="application_level"]').select("invoice");
// Set user restrictions
cy.get('select[name="user_restrictions.user_group"]').select("new");
cy.get('input[name="user_restrictions.min_purchase_count"]').type("0");
cy.get('input[name="user_restrictions.max_purchase_count"]').type("5");
cy.get('input[name="user_restrictions.referrer_user_id"]').type("123");
// Check warning about mutually exclusive options
cy.get('input[name="user_restrictions.new_users_only"]').check();
cy.get('input[name="user_restrictions.loyal_users_only"]').check();
// Should show warning
cy.contains(
"new_users_only و loyal_users_only نمی‌توانند همزمان فعال باشند"
).should("be.visible");
cy.get('button[type="submit"]').click();
cy.url().should("include", "/discount-codes");
cy.contains("کد تخفیف با موفقیت ایجاد شد").should("be.visible");
});
it("should validate form sections are properly organized", () => {
cy.get('[title="کد تخفیف جدید"]').click();
// Verify all sections exist
cy.contains("اطلاعات اصلی کد تخفیف").should("be.visible");
cy.contains("تنظیمات تخفیف").should("be.visible");
cy.contains("بازه زمانی اعتبار").should("be.visible");
cy.contains("محدودیت‌های کاربری").should("be.visible");
cy.contains("اطلاعات تکمیلی").should("be.visible");
// Verify form has proper styling
cy.get(".bg-gradient-to-r").should("have.length.greaterThan", 3);
cy.get(".rounded-xl").should("have.length.greaterThan", 3);
});
it("should handle back navigation properly", () => {
cy.get('[title="کد تخفیف جدید"]').click();
// Click back button
cy.contains("بازگشت").click();
// Should return to list page
cy.url().should("include", "/discount-codes");
cy.url().should("not.include", "/create");
cy.contains("مدیریت کدهای تخفیف").should("be.visible");
});
});

View File

@ -0,0 +1,168 @@
describe("Product Options Management", () => {
beforeEach(() => {
cy.login();
cy.visit("/product-options");
cy.waitForLoading();
});
it("should display product options list page", () => {
cy.contains("مدیریت گزینه‌های محصول").should("be.visible");
cy.contains("تنظیمات گزینه‌های قابل انتخاب برای محصولات").should(
"be.visible"
);
cy.get('[title="گزینه محصول جدید"]').should("be.visible");
});
it("should create a new product option", () => {
cy.get('[title="گزینه محصول جدید"]').click();
cy.url().should("include", "/product-options/create");
cy.contains("گزینه محصول جدید").should("be.visible");
// Fill product option form
cy.get('input[name="name"]').type("رنگ");
cy.get('textarea[name="description"]').type("انتخاب رنگ محصول");
cy.get('select[name="type"]').select("color");
// Add option values
cy.get("button").contains("افزودن گزینه").click();
cy.get('input[name="values[0].name"]').type("قرمز");
cy.get('input[name="values[0].value"]').type("#ff0000");
cy.get("button").contains("افزودن گزینه").click();
cy.get('input[name="values[1].name"]').type("آبی");
cy.get('input[name="values[1].value"]').type("#0000ff");
cy.get('button[type="submit"]').click();
cy.url().should("include", "/product-options");
cy.contains("گزینه محصول با موفقیت ایجاد شد").should("be.visible");
cy.contains("رنگ").should("be.visible");
});
it("should edit a product option", () => {
cy.get('[title="ویرایش"]').first().click();
cy.url().should("include", "/product-options/");
cy.url().should("include", "/edit");
// Update option
cy.get('input[name="name"]').clear().type("سایز");
cy.get('textarea[name="description"]').clear().type("انتخاب سایز محصول");
// Update values
cy.get('input[name="values[0].name"]').clear().type("کوچک");
cy.get('input[name="values[0].value"]').clear().type("S");
cy.get('button[type="submit"]').click();
cy.url().should("include", "/product-options");
cy.contains("گزینه محصول با موفقیت ویرایش شد").should("be.visible");
cy.contains("سایز").should("be.visible");
});
it("should delete a product option", () => {
cy.get('[title="حذف"]').first().click();
cy.get(".modal").should("be.visible");
cy.get("button").contains("حذف").click();
cy.contains("گزینه محصول با موفقیت حذف شد").should("be.visible");
});
it("should search product options", () => {
cy.get('input[placeholder*="جستجو"]').type("رنگ");
cy.get("button").contains("جستجو").click();
cy.waitForLoading();
cy.get("table tbody tr").should("contain", "رنگ");
});
it("should filter by option type", () => {
cy.get('select[name="type"]').select("color");
cy.get("button").contains("اعمال فیلتر").click();
cy.waitForLoading();
cy.get("table tbody tr").should("contain", "color");
});
it("should validate product option form", () => {
cy.get('[title="گزینه محصول جدید"]').click();
// Try to submit empty form
cy.get('button[type="submit"]').click();
cy.contains("نام گزینه الزامی است").should("be.visible");
cy.contains("نوع گزینه الزامی است").should("be.visible");
});
it("should validate option values", () => {
cy.get('[title="گزینه محصول جدید"]').click();
cy.get('input[name="name"]').type("رنگ");
cy.get('select[name="type"]').select("color");
// Add empty value
cy.get("button").contains("افزودن گزینه").click();
cy.get('button[type="submit"]').click();
cy.contains("نام گزینه الزامی است").should("be.visible");
});
it("should remove option value", () => {
cy.get('[title="گزینه محصول جدید"]').click();
cy.get('input[name="name"]').type("سایز");
cy.get('select[name="type"]').select("text");
// Add two values
cy.get("button").contains("افزودن گزینه").click();
cy.get('input[name="values[0].name"]').type("کوچک");
cy.get("button").contains("افزودن گزینه").click();
cy.get('input[name="values[1].name"]').type("بزرگ");
// Remove first value
cy.get('[data-testid="remove-value-0"]').click();
// Should have only one value now
cy.get('input[name="values[0].name"]').should("have.value", "بزرگ");
});
it("should show option usage in products", () => {
cy.get('[title="نمایش استفاده"]').first().click();
cy.get(".modal").should("be.visible");
cy.contains("محصولات استفاده کننده").should("be.visible");
});
it("should handle different option types", () => {
cy.get('[title="گزینه محصول جدید"]').click();
// Test color type
cy.get('select[name="type"]').select("color");
cy.get(".color-picker").should("be.visible");
// Test text type
cy.get('select[name="type"]').select("text");
cy.get('input[type="text"]').should("be.visible");
// Test number type
cy.get('select[name="type"]').select("number");
cy.get('input[type="number"]').should("be.visible");
});
it("should duplicate product option", () => {
cy.get('[title="کپی"]').first().click();
cy.url().should("include", "/product-options/create");
cy.get('input[name="name"]').should("contain.value", "(کپی)");
});
it("should export product options", () => {
cy.get("button").contains("خروجی").click();
// Should download file
cy.readFile("cypress/downloads/product-options.xlsx").should("exist");
});
});

View File

@ -0,0 +1,146 @@
describe("Products - Advanced Tests", () => {
beforeEach(() => {
cy.login();
});
describe("Product CRUD Operations", () => {
it("should create a new product with all fields", () => {
cy.visit("/products");
cy.get(".bg-primary-600.rounded-full").first().click();
// Fill basic product information
cy.get('input[name="name"]').type("تست محصول جدید");
cy.get('textarea[name="description"]').type("توضیحات کامل محصول تست");
cy.get('input[name="design_style"]').type("مدرن");
// Enable product
cy.get('input[name="enabled"]').check({ force: true });
// Set product type
cy.get('select[name="type"]').select("0");
// Submit form
cy.get('button[type="submit"]').click();
// Verify redirect and success message
cy.url().should("include", "/products");
cy.contains("تست محصول جدید").should("be.visible");
});
it("should edit an existing product", () => {
cy.visit("/products");
// Click edit on first product
cy.get("tbody tr")
.first()
.within(() => {
cy.get(
'[data-testid="edit-button"], [title="ویرایش"], .text-blue-600'
)
.first()
.click();
});
// Update product name
cy.get('input[name="name"]').clear().type("محصول ویرایش شده");
cy.get('button[type="submit"]').click();
// Verify changes
cy.url().should("include", "/products");
cy.contains("محصول ویرایش شده").should("be.visible");
});
it("should delete a product with confirmation", () => {
cy.visit("/products");
// Click delete on first product
cy.get("tbody tr")
.first()
.within(() => {
cy.get('[data-testid="delete-button"], [title="حذف"], .text-red-600')
.first()
.click();
});
// Confirm deletion in modal
cy.get('.modal, [role="dialog"]').should("be.visible");
cy.get("button").contains("حذف").click();
// Verify success message
cy.contains("محصول با موفقیت حذف شد", { timeout: 10000 }).should(
"be.visible"
);
});
});
describe("Product Form Validation", () => {
beforeEach(() => {
cy.visit("/products");
cy.get(".bg-primary-600.rounded-full").first().click();
});
it("should show validation errors for empty required fields", () => {
// Try to submit empty form
cy.get('button[type="submit"]').click();
// Check for validation messages
cy.contains("نام محصول الزامی است", { timeout: 5000 }).should(
"be.visible"
);
});
it("should validate minimum length for product name", () => {
cy.get('input[name="name"]').type("a");
cy.get('button[type="submit"]').click();
cy.contains("نام محصول باید حداقل", { timeout: 5000 }).should(
"be.visible"
);
});
});
describe("Product Search and Filter", () => {
beforeEach(() => {
cy.visit("/products");
});
it("should search products by name", () => {
cy.get('input[placeholder*="جستجو"], input[name="search"]').type("تست");
cy.get('button[type="submit"], button').contains("جستجو").click();
// Wait for results
cy.wait(2000);
// Check that search results contain the search term
cy.get("tbody tr").should("have.length.at.least", 0);
});
it("should filter products by category", () => {
cy.get('select[name="category_id"], select').first().select("1");
cy.get("button").contains("اعمال فیلتر").click();
cy.wait(2000);
// Results should be filtered
cy.get("tbody tr").should("have.length.at.least", 0);
});
});
describe("Product Status Management", () => {
beforeEach(() => {
cy.visit("/products");
});
it("should toggle product status", () => {
cy.get("tbody tr")
.first()
.within(() => {
cy.get('input[type="checkbox"], .toggle')
.first()
.click({ force: true });
});
cy.contains("وضعیت محصول با موفقیت تغییر کرد").should("be.visible");
});
});
});

144
cypress/e2e/products.cy.ts Normal file
View File

@ -0,0 +1,144 @@
describe("Product Management", () => {
beforeEach(() => {
cy.login();
cy.visit("/products");
cy.waitForLoading();
});
it("should display products list page", () => {
cy.contains("مدیریت محصولات").should("be.visible");
cy.contains("مدیریت محصولات، قیمت‌ها و موجودی").should("be.visible");
cy.get('[title="محصول جدید"]').should("be.visible");
});
it("should navigate to create product page", () => {
cy.get('[title="محصول جدید"]').click();
cy.url().should("include", "/products/create");
cy.contains("محصول جدید").should("be.visible");
});
it("should create a new product", () => {
cy.get('[title="محصول جدید"]').click();
// Fill product form
cy.get('input[name="name"]').type("محصول تست");
cy.get('textarea[name="description"]').type("توضیحات محصول تست");
cy.get('input[name="design_style"]').type("مدرن");
// Enable product
cy.get('input[name="enabled"]').check();
// Set product type
cy.get('select[name="type"]').select("0");
// Submit form
cy.get('button[type="submit"]').click();
// Should redirect to products list
cy.url().should("include", "/products");
cy.contains("محصول با موفقیت ایجاد شد").should("be.visible");
cy.contains("محصول تست").should("be.visible");
});
it("should search products", () => {
cy.get('input[placeholder*="جستجو"]').type("تست");
cy.get("button").contains("جستجو").click();
// Should filter results
cy.waitForLoading();
cy.get("table tbody tr").should("contain", "تست");
});
it("should filter products by category", () => {
cy.get("select").first().select("1"); // Assuming category with id 1 exists
cy.get("button").contains("اعمال فیلتر").click();
cy.waitForLoading();
// Results should be filtered by category
});
it("should edit a product", () => {
// Click edit button on first product
cy.get('[title="ویرایش"]').first().click();
cy.url().should("include", "/products/");
cy.url().should("include", "/edit");
// Update product name
cy.get('input[name="name"]').clear().type("محصول ویرایش شده");
cy.get('button[type="submit"]').click();
// Should redirect back to list
cy.url().should("include", "/products");
cy.contains("محصول با موفقیت ویرایش شد").should("be.visible");
cy.contains("محصول ویرایش شده").should("be.visible");
});
it("should delete a product", () => {
// Click delete button on first product
cy.get('[title="حذف"]').first().click();
// Confirm deletion
cy.get("button").contains("حذف").click();
cy.contains("محصول با موفقیت حذف شد").should("be.visible");
});
it("should manage product variants", () => {
cy.get('[title="محصول جدید"]').click();
// Fill basic product info
cy.get('input[name="name"]').type("محصول با واریانت");
cy.get('textarea[name="description"]').type("محصول تست با واریانت");
// Add variant
cy.get("button").contains("افزودن واریانت").click();
// Fill variant details
cy.get('input[name="variants[0].enabled"]').check();
cy.get('input[name="variants[0].fee_percentage"]').type("10");
cy.get('input[name="variants[0].profit_percentage"]').type("20");
cy.get('button[type="submit"]').click();
cy.contains("محصول با موفقیت ایجاد شد").should("be.visible");
});
it("should validate product form", () => {
cy.get('[title="محصول جدید"]').click();
// Try to submit empty form
cy.get('button[type="submit"]').click();
// Should show validation errors
cy.contains("نام محصول الزامی است").should("be.visible");
});
it("should handle pagination", () => {
// Assuming there are multiple pages of products
cy.get('[data-testid="pagination"]').should("be.visible");
// Go to next page
cy.get("button").contains("بعدی").click();
cy.waitForLoading();
// URL should change
cy.url().should("include", "page=2");
});
it("should sort products", () => {
// Click on sortable column header
cy.get("th").contains("نام").click();
cy.waitForLoading();
// Should sort by name
cy.url().should("include", "sort=name");
});
it("should export products list", () => {
cy.get("button").contains("خروجی").click();
// Should download file
cy.readFile("cypress/downloads/products.xlsx").should("exist");
});
});

View File

@ -0,0 +1,179 @@
describe("Roles - Advanced Tests", () => {
beforeEach(() => {
cy.login();
});
describe("Role CRUD Operations", () => {
it("should create a new role", () => {
cy.visit("/roles");
cy.get(".bg-primary-600.rounded-full").first().click();
// Fill role information
cy.get('input[name="name"]').type("نقش تست");
cy.get('textarea[name="description"]').type("توضیحات نقش تست");
// Enable role
cy.get('input[name="enabled"]').check({ force: true });
// Submit form
cy.get('button[type="submit"]').click();
// Verify redirect and success
cy.url().should("include", "/roles");
cy.contains("نقش تست").should("be.visible");
});
it("should edit an existing role", () => {
cy.visit("/roles");
// Click edit on first role
cy.get("tbody tr")
.first()
.within(() => {
cy.get(
'[data-testid="edit-button"], [title="ویرایش"], .text-blue-600'
)
.first()
.click();
});
// Update role name
cy.get('input[name="name"]').clear().type("نقش ویرایش شده");
cy.get('button[type="submit"]').click();
// Verify changes
cy.url().should("include", "/roles");
cy.contains("نقش ویرایش شده").should("be.visible");
});
it("should delete a role with confirmation", () => {
cy.visit("/roles");
// Click delete on first role (skip admin role)
cy.get("tbody tr")
.eq(1)
.within(() => {
cy.get('[data-testid="delete-button"], [title="حذف"], .text-red-600')
.first()
.click();
});
// Confirm deletion in modal
cy.get('.modal, [role="dialog"]').should("be.visible");
cy.get("button").contains("حذف").click();
// Verify success message
cy.contains("نقش با موفقیت حذف شد", { timeout: 10000 }).should(
"be.visible"
);
});
});
describe("Role Form Validation", () => {
beforeEach(() => {
cy.visit("/roles");
cy.get(".bg-primary-600.rounded-full").first().click();
});
it("should show validation errors for empty required fields", () => {
// Try to submit empty form
cy.get('button[type="submit"]').click();
// Check for validation messages
cy.contains("نام نقش الزامی است", { timeout: 5000 }).should("be.visible");
});
it("should validate minimum length for role name", () => {
cy.get('input[name="name"]').type("a");
cy.get('button[type="submit"]').click();
cy.contains("نام نقش باید حداقل", { timeout: 5000 }).should("be.visible");
});
});
describe("Role Permissions Management", () => {
beforeEach(() => {
cy.visit("/roles");
});
it("should manage role permissions", () => {
// Click permissions on first role
cy.get("tbody tr")
.first()
.within(() => {
cy.get('[data-testid="permissions-button"], [title="مجوزها"], button')
.contains("مجوزها")
.click();
});
// Should navigate to permissions page
cy.url().should("include", "/roles/");
cy.url().should("include", "/permissions");
cy.contains("مدیریت مجوزهای نقش").should("be.visible");
});
it("should assign permissions to role", () => {
cy.get("tbody tr")
.first()
.within(() => {
cy.get('[data-testid="permissions-button"], [title="مجوزها"], button')
.contains("مجوزها")
.click();
});
// Toggle some permissions
cy.get('input[type="checkbox"]').first().click({ force: true });
// Save changes
cy.get('button[type="submit"]').click();
cy.contains("مجوزهای نقش با موفقیت به‌روزرسانی شد").should("be.visible");
});
});
describe("Role Search and Filter", () => {
beforeEach(() => {
cy.visit("/roles");
});
it("should search roles by name", () => {
cy.get('input[placeholder*="جستجو"], input[name="search"]').type("admin");
cy.get('button[type="submit"], button').contains("جستجو").click();
// Wait for results
cy.wait(2000);
// Check search results
cy.get("tbody tr").should("have.length.at.least", 0);
});
it("should filter roles by status", () => {
cy.get('select[name="enabled"], select').first().select("true");
cy.get("button").contains("اعمال فیلتر").click();
cy.wait(2000);
// Results should be filtered
cy.get("tbody tr").should("have.length.at.least", 0);
});
});
describe("Role Status Management", () => {
beforeEach(() => {
cy.visit("/roles");
});
it("should toggle role status", () => {
// Skip admin role, use second role
cy.get("tbody tr")
.eq(1)
.within(() => {
cy.get('input[type="checkbox"], .toggle')
.first()
.click({ force: true });
});
cy.contains("وضعیت نقش با موفقیت تغییر کرد").should("be.visible");
});
});
});

View File

@ -0,0 +1,230 @@
describe("Roles and Permissions Management", () => {
beforeEach(() => {
cy.login();
});
describe("Roles Management", () => {
beforeEach(() => {
cy.visit("/roles");
cy.waitForLoading();
});
it("should display roles list page", () => {
cy.contains("مدیریت نقش‌ها").should("be.visible");
cy.contains("مدیریت نقش‌ها و دسترسی‌های سیستم").should("be.visible");
cy.get('[title="نقش جدید"]').should("be.visible");
});
it("should create a new role", () => {
cy.get('[title="نقش جدید"]').click();
cy.url().should("include", "/roles/create");
cy.contains("نقش جدید").should("be.visible");
// Fill role form
cy.get('input[name="name"]').type("مدیر محصولات");
cy.get('textarea[name="description"]').type(
"مسئول مدیریت محصولات و کاتگوری‌ها"
);
cy.get('button[type="submit"]').click();
cy.url().should("include", "/roles");
cy.contains("نقش با موفقیت ایجاد شد").should("be.visible");
cy.contains("مدیر محصولات").should("be.visible");
});
it("should edit a role", () => {
cy.get('[title="ویرایش"]').first().click();
cy.url().should("include", "/roles/");
cy.url().should("include", "/edit");
cy.get('input[name="name"]').clear().type("مدیر فروش");
cy.get('textarea[name="description"]')
.clear()
.type("مسئول مدیریت فروش و سفارشات");
cy.get('button[type="submit"]').click();
cy.url().should("include", "/roles");
cy.contains("نقش با موفقیت ویرایش شد").should("be.visible");
cy.contains("مدیر فروش").should("be.visible");
});
it("should delete a role", () => {
cy.get('[title="حذف"]').first().click();
cy.get(".modal").should("be.visible");
cy.get("button").contains("حذف").click();
cy.contains("نقش با موفقیت حذف شد").should("be.visible");
});
it("should view role details", () => {
cy.get('[title="مشاهده جزئیات"]').first().click();
cy.url().should("include", "/roles/");
cy.contains("جزئیات نقش").should("be.visible");
cy.contains("لیست کاربران").should("be.visible");
cy.contains("دسترسی‌ها").should("be.visible");
});
it("should manage role permissions", () => {
cy.get('[title="مدیریت دسترسی‌ها"]').first().click();
cy.url().should("include", "/roles/");
cy.url().should("include", "/permissions");
cy.contains("مدیریت دسترسی‌های نقش").should("be.visible");
// Assign permission
cy.get('input[type="checkbox"]').first().check();
cy.get("button").contains("ذخیره تغییرات").click();
cy.contains("دسترسی‌ها با موفقیت به‌روزرسانی شد").should("be.visible");
});
it("should search roles", () => {
cy.get('input[placeholder*="جستجو"]').type("مدیر");
cy.get("button").contains("جستجو").click();
cy.waitForLoading();
cy.get("table tbody tr").should("contain", "مدیر");
});
it("should validate role form", () => {
cy.get('[title="نقش جدید"]').click();
cy.get('button[type="submit"]').click();
cy.contains("نام نقش الزامی است").should("be.visible");
});
});
describe("Permissions Management", () => {
beforeEach(() => {
cy.visit("/permissions");
cy.waitForLoading();
});
it("should display permissions list page", () => {
cy.contains("لیست دسترسی‌ها").should("be.visible");
cy.contains("نمایش دسترسی‌های سیستم").should("be.visible");
cy.get('[title="دسترسی جدید"]').should("be.visible");
});
it("should create a new permission", () => {
cy.get('[title="دسترسی جدید"]').click();
cy.url().should("include", "/permissions/create");
cy.contains("دسترسی جدید").should("be.visible");
// Fill permission form
cy.get('input[name="title"]').type("مدیریت کاربران");
cy.get('textarea[name="description"]').type(
"دسترسی به مدیریت کاربران سیستم"
);
cy.get('input[name="resource"]').type("users");
cy.get('input[name="action"]').type("manage");
cy.get('button[type="submit"]').click();
cy.url().should("include", "/permissions");
cy.contains("دسترسی با موفقیت ایجاد شد").should("be.visible");
cy.contains("مدیریت کاربران").should("be.visible");
});
it("should edit a permission", () => {
cy.get('[title="ویرایش"]').first().click();
cy.url().should("include", "/permissions/");
cy.url().should("include", "/edit");
cy.get('input[name="title"]').clear().type("نمایش کاربران");
cy.get('input[name="action"]').clear().type("view");
cy.get('button[type="submit"]').click();
cy.url().should("include", "/permissions");
cy.contains("دسترسی با موفقیت ویرایش شد").should("be.visible");
cy.contains("نمایش کاربران").should("be.visible");
});
it("should delete a permission", () => {
cy.get('[title="حذف"]').first().click();
cy.get(".modal").should("be.visible");
cy.get("button").contains("حذف").click();
cy.contains("دسترسی با موفقیت حذف شد").should("be.visible");
});
it("should search permissions", () => {
cy.get('input[placeholder*="جستجو"]').type("کاربر");
cy.get("button").contains("جستجو").click();
cy.waitForLoading();
cy.get("table tbody tr").should("contain", "کاربر");
});
it("should filter permissions by resource", () => {
cy.get('select[name="resource"]').select("products");
cy.get("button").contains("اعمال فیلتر").click();
cy.waitForLoading();
cy.get("table tbody tr").should("contain", "products");
});
it("should validate permission form", () => {
cy.get('[title="دسترسی جدید"]').click();
cy.get('button[type="submit"]').click();
cy.contains("عنوان دسترسی الزامی است").should("be.visible");
cy.contains("منبع الزامی است").should("be.visible");
cy.contains("عمل الزامی است").should("be.visible");
});
it("should show permission usage in roles", () => {
cy.get('[title="نمایش استفاده"]').first().click();
cy.get(".modal").should("be.visible");
cy.contains("نقش‌های دارای این دسترسی").should("be.visible");
});
});
describe("Role-Permission Assignment", () => {
it("should assign multiple permissions to role", () => {
cy.visit("/roles");
cy.get('[title="مدیریت دسترسی‌ها"]').first().click();
// Select multiple permissions
cy.get('input[type="checkbox"]').check(["1", "2", "3"]);
cy.get("button").contains("ذخیره تغییرات").click();
cy.contains("دسترسی‌ها با موفقیت به‌روزرسانی شد").should("be.visible");
});
it("should remove permission from role", () => {
cy.visit("/roles");
cy.get('[title="مدیریت دسترسی‌ها"]').first().click();
// Uncheck permission
cy.get('input[type="checkbox"]:checked').first().uncheck();
cy.get("button").contains("ذخیره تغییرات").click();
cy.contains("دسترسی‌ها با موفقیت به‌روزرسانی شد").should("be.visible");
});
it("should show permission hierarchy", () => {
cy.visit("/roles");
cy.get('[title="مدیریت دسترسی‌ها"]').first().click();
// Should show permissions grouped by category
cy.contains("کاربران").should("be.visible");
cy.contains("محصولات").should("be.visible");
cy.contains("سیستم").should("be.visible");
});
});
});

58
cypress/e2e/smoke.cy.ts Normal file
View File

@ -0,0 +1,58 @@
describe("Smoke Tests", () => {
it("should load the application", () => {
cy.visit("/login");
cy.contains("ورود به پنل مدیریت").should("be.visible");
});
it("should complete basic user flow", () => {
// Login
cy.login();
// Navigate to dashboard
cy.visit("/");
cy.contains("داشبورد").should("be.visible");
// Check navigation works
cy.visit("/products");
cy.url().should("include", "/products");
cy.visit("/discount-codes");
cy.url().should("include", "/discount-codes");
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");
cy.visit("/roles");
cy.url().should("include", "/roles");
// Check logout works by visiting login page
cy.visit("/login");
cy.url().should("include", "/login");
});
it("should handle API errors gracefully", () => {
cy.intercept("GET", "**/api/**", { statusCode: 500 }).as("apiError");
cy.login();
cy.visit("/products");
cy.wait("@apiError");
// Check for loading or error state
cy.get("body").should("be.visible");
});
it("should work in different browsers", () => {
cy.login();
cy.visit("/");
// Basic functionality should work
cy.contains("داشبورد").should("be.visible");
cy.get("header").should("be.visible");
});
});

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

@ -0,0 +1,180 @@
describe("Users - Advanced Tests", () => {
beforeEach(() => {
cy.login();
});
describe("User CRUD Operations", () => {
it("should create a new admin user", () => {
cy.visit("/admin-users");
cy.get(".bg-primary-600.rounded-full").first().click();
// Fill user information
cy.get('input[name="first_name"]').type("کاربر");
cy.get('input[name="last_name"]').type("تست");
cy.get('input[name="username"]').type("test-user-" + Date.now());
cy.get('input[name="password"]').type("Test123456");
cy.get('input[name="password_confirmation"]').type("Test123456");
// Enable user
cy.get('input[name="enabled"]').check({ force: true });
// Submit form
cy.get('button[type="submit"]').click();
// Verify redirect
cy.url().should("include", "/admin-users");
cy.contains("کاربر تست").should("be.visible");
});
it("should edit an existing user", () => {
cy.visit("/admin-users");
// Click edit on first user
cy.get("tbody tr")
.first()
.within(() => {
cy.get(
'[data-testid="edit-button"], [title="ویرایش"], .text-blue-600'
)
.first()
.click();
});
// Update user info
cy.get('input[name="first_name"]').clear().type("کاربر ویرایش شده");
cy.get('button[type="submit"]').click();
// Verify changes
cy.url().should("include", "/admin-users");
cy.contains("کاربر ویرایش شده").should("be.visible");
});
it("should delete a user with confirmation", () => {
cy.visit("/admin-users");
// Click delete on first user
cy.get("tbody tr")
.first()
.within(() => {
cy.get('[data-testid="delete-button"], [title="حذف"], .text-red-600')
.first()
.click();
});
// Confirm deletion in modal
cy.get('.modal, [role="dialog"]').should("be.visible");
cy.get("button").contains("حذف").click();
// Verify success message
cy.contains("کاربر با موفقیت حذف شد", { timeout: 10000 }).should(
"be.visible"
);
});
});
describe("User Form Validation", () => {
beforeEach(() => {
cy.visit("/admin-users");
cy.get(".bg-primary-600.rounded-full").first().click();
});
it("should show validation errors for empty required fields", () => {
// Try to submit empty form
cy.get('button[type="submit"]').click();
// Check for validation messages
cy.contains("نام الزامی است", { timeout: 5000 }).should("be.visible");
});
it("should validate password confirmation", () => {
cy.get('input[name="first_name"]').type("تست");
cy.get('input[name="last_name"]').type("کاربر");
cy.get('input[name="username"]').type("testuser");
cy.get('input[name="password"]').type("password123");
cy.get('input[name="password_confirmation"]').type("different");
cy.get('button[type="submit"]').click();
cy.contains("تأیید رمز عبور مطابقت ندارد").should("be.visible");
});
it("should validate minimum password length", () => {
cy.get('input[name="password"]').type("123");
cy.get('button[type="submit"]').click();
cy.contains("رمز عبور باید حداقل", { timeout: 5000 }).should(
"be.visible"
);
});
});
describe("User Search and Filter", () => {
beforeEach(() => {
cy.visit("/admin-users");
});
it("should search users by name", () => {
cy.get('input[placeholder*="جستجو"], input[name="search"]').type("admin");
cy.get('button[type="submit"], button').contains("جستجو").click();
// Wait for results
cy.wait(2000);
// Check search results
cy.get("tbody tr").should("have.length.at.least", 0);
});
it("should filter users by status", () => {
cy.get('select[name="enabled"], select').first().select("true");
cy.get("button").contains("اعمال فیلتر").click();
cy.wait(2000);
// Results should be filtered
cy.get("tbody tr").should("have.length.at.least", 0);
});
});
describe("User Status Management", () => {
beforeEach(() => {
cy.visit("/admin-users");
});
it("should toggle user status", () => {
cy.get("tbody tr")
.first()
.within(() => {
cy.get('input[type="checkbox"], .toggle')
.first()
.click({ force: true });
});
cy.contains("وضعیت کاربر با موفقیت تغییر کرد").should("be.visible");
});
});
describe("User Import/Export", () => {
beforeEach(() => {
cy.visit("/admin-users");
});
it("should show import modal", () => {
cy.get("button").contains("وارد کردن").click();
cy.get('.modal, [role="dialog"]').should("be.visible");
cy.contains("وارد کردن کاربران از فایل Excel").should("be.visible");
});
it("should validate file upload format", () => {
cy.get("button").contains("وارد کردن").click();
// Upload invalid file type
cy.get('input[type="file"]').selectFile(
"cypress/fixtures/invalid-file.txt",
{ force: true }
);
cy.contains("فرمت فایل باید xlsx باشد").should("be.visible");
});
});
});

131
cypress/e2e/users.cy.ts Normal file
View File

@ -0,0 +1,131 @@
describe("User Management", () => {
beforeEach(() => {
cy.login();
cy.visit("/admin-users");
cy.waitForLoading();
});
it("should display admin users list page", () => {
cy.contains("مدیریت کاربران ادمین").should("be.visible");
cy.contains("مدیریت کاربران دسترسی به پنل ادمین").should("be.visible");
cy.get('[title="کاربر ادمین جدید"]').should("be.visible");
});
it("should create a new admin user", () => {
cy.get('[title="کاربر ادمین جدید"]').click();
cy.url().should("include", "/admin-users/create");
cy.contains("کاربر ادمین جدید").should("be.visible");
// Fill user form
cy.get('input[name="first_name"]').type("احمد");
cy.get('input[name="last_name"]').type("محمدی");
cy.get('input[name="username"]').type("ahmad.mohammadi");
// Email field removed as admin users only need username
cy.get('input[name="password"]').type("password123");
// Phone field not available in admin user form
// Set status
cy.get('select[name="status"]').select("active");
cy.get('button[type="submit"]').click();
cy.url().should("include", "/admin-users");
cy.contains("کاربر با موفقیت ایجاد شد").should("be.visible");
cy.contains("احمد محمدی").should("be.visible");
});
it("should search admin users", () => {
cy.get('input[placeholder*="جستجو"]').type("احمد");
cy.get("button").contains("جستجو").click();
cy.waitForLoading();
cy.get("table tbody tr").should("contain", "احمد");
});
it("should filter users by role", () => {
cy.get("select").contains("نقش").select("مدیر");
cy.get("button").contains("اعمال فیلتر").click();
cy.waitForLoading();
// Results should be filtered by role
});
it("should edit an admin user", () => {
cy.get('[title="ویرایش"]').first().click();
cy.url().should("include", "/admin-users/");
cy.url().should("include", "/edit");
// Update user info
cy.get('input[name="first_name"]').clear().type("علی");
cy.get('input[name="last_name"]').clear().type("احمدی");
cy.get('button[type="submit"]').click();
cy.url().should("include", "/admin-users");
cy.contains("کاربر با موفقیت ویرایش شد").should("be.visible");
cy.contains("علی احمدی").should("be.visible");
});
it("should delete an admin user", () => {
cy.get('[title="حذف"]').first().click();
// Confirm deletion in modal
cy.get(".modal").should("be.visible");
cy.get("button").contains("حذف").click();
cy.contains("کاربر با موفقیت حذف شد").should("be.visible");
});
it("should validate admin user form", () => {
cy.get('[title="کاربر ادمین جدید"]').click();
// Try to submit empty form
cy.get('button[type="submit"]').click();
// Should show validation errors
cy.contains("نام الزامی است").should("be.visible");
cy.contains("نام خانوادگی الزامی است").should("be.visible");
cy.contains("نام کاربری الزامی است").should("be.visible");
// Email not required for admin users
});
it("should validate username format", () => {
cy.get('[title="کاربر ادمین جدید"]').click();
cy.get('input[name="username"]').type("ab"); // خیلی کوتاه
cy.get('button[type="submit"]').click();
cy.contains("نام کاربری باید حداقل 3 کاراکتر باشد").should("be.visible");
});
it("should validate username uniqueness", () => {
cy.get('[title="کاربر ادمین جدید"]').click();
// Fill form with existing username
cy.get('input[name="first_name"]').type("تست");
cy.get('input[name="last_name"]').type("کاربر");
cy.get('input[name="username"]').type("admin"); // Assuming 'admin' already exists
cy.get('input[name="password"]').type("password123");
cy.get('button[type="submit"]').click();
cy.contains("نام کاربری قبلاً استفاده شده است").should("be.visible");
});
it("should handle user status toggle", () => {
// Assuming there's a toggle for user status
cy.get('[data-testid="user-status-toggle"]').first().click();
cy.contains("وضعیت کاربر با موفقیت تغییر کرد").should("be.visible");
});
it("should display user activity logs", () => {
cy.get('[title="لاگ فعالیت"]').first().click();
cy.get(".modal").should("be.visible");
cy.contains("لاگ فعالیت کاربر").should("be.visible");
cy.get("table").should("be.visible");
});
});

View File

@ -0,0 +1,2 @@
# This would be a test image file
# For demo purposes, this represents an image placeholder

View File

@ -0,0 +1 @@
# This is an invalid file format for testing file upload validation

View File

@ -0,0 +1,36 @@
// ***********************************************
// This example commands.ts shows you how to
// create various custom commands and overwrite
// existing commands.
//
// For more comprehensive examples of custom
// commands please read more here:
// https://on.cypress.io/custom-commands
// ***********************************************
Cypress.Commands.add("login", (username = "admin", password = "admin123") => {
cy.visit("/login");
cy.get('input[name="username"]').type(username);
cy.get('input[name="password"]').type(password);
cy.get('button[type="submit"]').click();
cy.url().should("not.include", "/login");
cy.contains("داشبورد", { timeout: 10000 }).should("be.visible");
});
Cypress.Commands.add("logout", () => {
cy.get(".bg-primary-600.rounded-full").first().click();
cy.contains("خروج").click();
cy.url().should("include", "/login");
});
Cypress.Commands.add("getByTestId", (testId: string) => {
return cy.get(`[data-testid="${testId}"]`);
});
Cypress.Commands.add("waitForLoading", () => {
// Wait for any loading spinner to disappear
cy.get(".animate-spin", { timeout: 1000 }).should("not.exist");
});
// Import discount codes helpers
import "./discount-codes-helpers";

View File

@ -0,0 +1,38 @@
// ***********************************************************
// This example support/component.ts is processed and
// loaded automatically before your test files.
//
// This is a great place to put global configuration and
// behavior that modifies Cypress.
//
// You can change the location of this file or turn off
// automatically serving support files with the
// 'supportFile' configuration option.
//
// You can read more here:
// https://on.cypress.io/configuration
// ***********************************************************
// Import commands.js using ES2015 syntax:
import "./commands";
// Alternatively you can use CommonJS syntax:
// require('./commands')
import { mount } from "cypress/react18";
// Augment the Cypress namespace to include type definitions for
// your custom command.
// Alternatively, you can type this at the top of your test file.
declare global {
namespace Cypress {
interface Chainable {
mount: typeof mount;
}
}
}
Cypress.Commands.add("mount", mount);
// Example use:
// cy.mount(<MyComponent />)

View File

@ -0,0 +1,310 @@
// Helper functions for discount codes E2E tests
export interface DiscountCodeData {
code: string;
name: string;
description?: string;
type: "percentage" | "fixed" | "fee_percentage";
value: string;
status: "active" | "inactive";
applicationLevel:
| "invoice"
| "category"
| "product"
| "shipping"
| "product_fee";
minPurchaseAmount?: string;
maxDiscountAmount?: string;
usageLimit?: string;
userUsageLimit?: string;
singleUse?: boolean;
validFrom?: string;
validTo?: string;
userGroup?: "new" | "loyal" | "all";
newUsersOnly?: boolean;
loyalUsersOnly?: boolean;
campaign?: string;
category?: string;
}
declare global {
namespace Cypress {
interface Chainable {
createDiscountCode(data: DiscountCodeData): Chainable<void>;
fillBasicDiscountInfo(data: Partial<DiscountCodeData>): Chainable<void>;
fillDiscountSettings(data: Partial<DiscountCodeData>): Chainable<void>;
fillUserRestrictions(data: Partial<DiscountCodeData>): Chainable<void>;
submitDiscountForm(): Chainable<void>;
verifyDiscountCreation(): Chainable<void>;
navigateToCreateDiscount(): Chainable<void>;
searchDiscountCode(code: string): Chainable<void>;
clearDiscountFilters(): Chainable<void>;
}
}
}
// Navigate to create discount page
Cypress.Commands.add("navigateToCreateDiscount", () => {
cy.visit("/discount-codes");
cy.waitForLoading();
cy.getByTestId("create-discount-button").click();
cy.url().should("include", "/discount-codes/create");
});
// Fill basic discount information
Cypress.Commands.add(
"fillBasicDiscountInfo",
(data: Partial<DiscountCodeData>) => {
if (data.code) {
cy.getByTestId("discount-code-input").clear().type(data.code);
}
if (data.name) {
cy.getByTestId("discount-name-input").clear().type(data.name);
}
if (data.description) {
cy.getByTestId("discount-description-textarea")
.clear()
.type(data.description);
}
}
);
// Fill discount settings
Cypress.Commands.add(
"fillDiscountSettings",
(data: Partial<DiscountCodeData>) => {
if (data.type) {
cy.getByTestId("discount-type-select").select(data.type);
}
if (data.value) {
cy.getByTestId("discount-value-input").clear().type(data.value);
}
if (data.status) {
cy.getByTestId("discount-status-select").select(data.status);
}
if (data.applicationLevel) {
cy.getByTestId("discount-application-level-select").select(
data.applicationLevel
);
}
if (data.minPurchaseAmount) {
cy.get('input[name="min_purchase_amount"]')
.clear()
.type(data.minPurchaseAmount);
}
if (data.maxDiscountAmount) {
cy.get('input[name="max_discount_amount"]')
.clear()
.type(data.maxDiscountAmount);
}
if (data.usageLimit) {
cy.get('input[name="usage_limit"]').clear().type(data.usageLimit);
}
if (data.userUsageLimit) {
cy.get('input[name="user_usage_limit"]')
.clear()
.type(data.userUsageLimit);
}
if (data.singleUse) {
cy.get('input[name="single_use"]').check();
}
if (data.validFrom) {
cy.get('input[name="valid_from"]').type(data.validFrom);
}
if (data.validTo) {
cy.get('input[name="valid_to"]').type(data.validTo);
}
}
);
// Fill user restrictions
Cypress.Commands.add(
"fillUserRestrictions",
(data: Partial<DiscountCodeData>) => {
if (data.userGroup) {
cy.get('select[name="user_restrictions.user_group"]').select(
data.userGroup
);
}
if (data.newUsersOnly) {
cy.get('input[name="user_restrictions.new_users_only"]').check();
}
if (data.loyalUsersOnly) {
cy.get('input[name="user_restrictions.loyal_users_only"]').check();
}
if (data.campaign) {
cy.get('input[name="meta.campaign"]').clear().type(data.campaign);
}
if (data.category) {
cy.get('input[name="meta.category"]').clear().type(data.category);
}
}
);
// Submit discount form
Cypress.Commands.add("submitDiscountForm", () => {
cy.getByTestId("submit-discount-button").click();
});
// Verify discount creation
Cypress.Commands.add("verifyDiscountCreation", () => {
cy.url().should("include", "/discount-codes");
cy.url().should("not.include", "/create");
cy.url().should("not.include", "/edit");
});
// Create complete discount code
Cypress.Commands.add("createDiscountCode", (data: DiscountCodeData) => {
cy.navigateToCreateDiscount();
cy.fillBasicDiscountInfo(data);
cy.fillDiscountSettings(data);
cy.fillUserRestrictions(data);
cy.submitDiscountForm();
cy.verifyDiscountCreation();
});
// Search for discount code
Cypress.Commands.add("searchDiscountCode", (code: string) => {
cy.get('input[placeholder*="جستجو"]').clear().type(code);
cy.wait(500); // Wait for search to filter
});
// Clear discount filters
Cypress.Commands.add("clearDiscountFilters", () => {
cy.contains("پاک کردن فیلترها").click();
cy.get('input[placeholder*="جستجو"]').should("have.value", "");
});
// Predefined discount code templates for testing
export const discountTemplates = {
basicPercentage: {
code: "BASIC20",
name: "Basic 20% Discount",
description: "Basic percentage discount for testing",
type: "percentage" as const,
value: "20",
status: "active" as const,
applicationLevel: "invoice" as const,
},
fixedAmount: {
code: "FIXED50K",
name: "Fixed 50K Discount",
description: "Fixed amount discount for testing",
type: "fixed" as const,
value: "50000",
status: "active" as const,
applicationLevel: "invoice" as const,
minPurchaseAmount: "100000",
},
feePercentage: {
code: "FEERED10",
name: "Fee Reduction 10%",
description: "Fee percentage reduction for testing",
type: "fee_percentage" as const,
value: "10",
status: "active" as const,
applicationLevel: "product_fee" as const,
},
loyalUsers: {
code: "LOYAL25",
name: "Loyal Users 25%",
description: "Discount for loyal users only",
type: "percentage" as const,
value: "25",
status: "active" as const,
applicationLevel: "invoice" as const,
userGroup: "loyal" as const,
loyalUsersOnly: true,
},
newUsers: {
code: "WELCOME15",
name: "Welcome New Users",
description: "Welcome discount for new users",
type: "percentage" as const,
value: "15",
status: "active" as const,
applicationLevel: "invoice" as const,
userGroup: "new" as const,
newUsersOnly: true,
singleUse: true,
},
timeBasedDiscount: {
code: "SUMMER24",
name: "Summer Sale 2024",
description: "Summer sale discount with time constraints",
type: "percentage" as const,
value: "30",
status: "active" as const,
applicationLevel: "invoice" as const,
validFrom: "2024-06-01T00:00",
validTo: "2024-08-31T23:59",
usageLimit: "1000",
userUsageLimit: "1",
campaign: "summer_sale_2024",
category: "seasonal",
},
};
// API response mocks
export const apiMocks = {
successfulCreation: (data: Partial<DiscountCodeData>) => ({
statusCode: 201,
body: {
id: Math.floor(Math.random() * 1000),
code: data.code,
name: data.name,
description: data.description,
type: data.type,
value: parseFloat(data.value || "0"),
status: data.status,
application_level: data.applicationLevel,
created_at: new Date().toISOString(),
updated_at: new Date().toISOString(),
},
}),
validationError: {
statusCode: 400,
body: {
message: "کد تخفیف تکراری است",
errors: {
code: ["این کد قبلاً استفاده شده است"],
},
},
},
serverError: {
statusCode: 500,
body: {
message: "خطای سرور",
},
},
discountsList: {
statusCode: 200,
body: {
discount_codes: [
{
id: 1,
code: "SAVE20",
name: "20% Off Discount",
description: "Get 20% off on your purchase",
type: "percentage",
value: 20,
status: "active",
application_level: "invoice",
created_at: "2024-01-01T00:00:00Z",
},
],
total: 1,
page: 1,
limit: 20,
total_pages: 1,
},
},
};

31
cypress/support/e2e.ts Normal file
View File

@ -0,0 +1,31 @@
// ***********************************************************
// This example support/e2e.ts is processed and
// loaded automatically before your test files.
//
// This is a great place to put global configuration and
// behavior that modifies Cypress.
//
// You can change the location of this file or turn off
// automatically serving support files with the
// 'supportFile' configuration option.
//
// You can read more here:
// https://on.cypress.io/configuration
// ***********************************************************
// Import commands.js using ES2015 syntax:
import "./commands";
// Alternatively you can use CommonJS syntax:
// require('./commands')
declare global {
namespace Cypress {
interface Chainable {
login(username?: string, password?: string): Chainable<void>;
logout(): Chainable<void>;
getByTestId(testId: string): Chainable<JQuery<HTMLElement>>;
waitForLoading(): Chainable<void>;
}
}
}

19
cypress/support/index.d.ts vendored Normal file
View File

@ -0,0 +1,19 @@
declare namespace Cypress {
interface Chainable {
login(username?: string, password?: string): Chainable<void>;
logout(): Chainable<void>;
getByTestId(testId: string): Chainable<JQuery<HTMLElement>>;
waitForLoading(): Chainable<void>;
// Discount codes helper methods
navigateToCreateDiscount(): Chainable<void>;
fillBasicDiscountInfo(data: any): Chainable<void>;
fillDiscountSettings(data: any): Chainable<void>;
fillUserRestrictions(data: any): Chainable<void>;
submitDiscountForm(): Chainable<void>;
verifyDiscountCreation(): Chainable<void>;
createDiscountCode(data: any): Chainable<void>;
searchDiscountCode(code: string): Chainable<void>;
clearDiscountFilters(): Chainable<void>;
}
}

Binary file not shown.

9
docker-compose.stage.yml Normal file
View File

@ -0,0 +1,9 @@
version: '3.8'
services:
mazane-stage-backoffice:
image: mazane-backoffice:latest
restart: always
ports:
- "127.0.0.1:3099:80"
# docker compose -p mazane_stage_backoffice -f ./docker-compose.stage.yml up -d

2606
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -7,38 +7,55 @@
"dev": "vite",
"build": "tsc && vite build",
"lint": "eslint . --ext ts,tsx --report-unused-disable-directives --max-warnings 0",
"preview": "vite preview"
"preview": "vite preview",
"swagger": "python3 -m http.server 8000 && open http://localhost:8000/swagger-ui.html",
"cypress:open": "cypress open",
"cypress:run": "cypress run",
"cypress:run:headless": "cypress run --headless",
"test:e2e": "start-server-and-test dev http://localhost:5173/ cypress:run",
"test:e2e:open": "start-server-and-test dev http://localhost:5173/ cypress:open"
},
"dependencies": {
"@headlessui/react": "^1.7.17",
"@hookform/resolvers": "^5.1.1",
"@tanstack/react-query": "^5.80.6",
"@tanstack/react-query-devtools": "^5.80.6",
"@types/js-cookie": "^3.0.6",
"apexcharts": "^5.3.6",
"axios": "^1.9.0",
"clsx": "^2.0.0",
"js-cookie": "^3.0.5",
"lucide-react": "^0.263.1",
"react": "^18.2.0",
"react-dom": "^18.2.0",
"react": "^19.2.3",
"react-apexcharts": "^1.9.0",
"react-date-object": "2.1.9",
"react-dom": "^19.2.3",
"react-hook-form": "^7.57.0",
"react-hot-toast": "^2.5.2",
"react-multi-date-picker": "4.5.2",
"react-router-dom": "^6.15.0",
"recharts": "^2.8.0",
"yup": "^1.6.1",
"zustand": "^5.0.5"
},
"devDependencies": {
"@cypress/react18": "^2.0.1",
"@cypress/vite-dev-server": "^6.0.3",
"@types/node": "^24.0.0",
"@types/react": "^18.2.15",
"@types/react-dom": "^18.2.7",
"@types/react": "^19.2.0",
"@types/react-dom": "^19.2.0",
"@typescript-eslint/eslint-plugin": "^6.0.0",
"@typescript-eslint/parser": "^6.0.0",
"@vitejs/plugin-react": "^4.0.3",
"autoprefixer": "^10.4.14",
"cypress": "^14.5.3",
"eslint": "^8.45.0",
"eslint-plugin-react-hooks": "^4.6.0",
"eslint-plugin-react-refresh": "^0.4.3",
"postcss": "^8.4.27",
"start-server-and-test": "^2.0.12",
"tailwindcss": "^3.3.3",
"terser": "^5.43.1",
"typescript": "^5.0.2",
"vite": "^4.4.5"
}

View File

@ -1,23 +1,102 @@
import { BrowserRouter as Router, Routes, Route, Navigate } from 'react-router-dom';
import { QueryClientProvider } from '@tanstack/react-query';
import { ReactQueryDevtools } from '@tanstack/react-query-devtools';
import { Suspense, lazy } from 'react';
import { AuthProvider } from './contexts/AuthContext';
import { ThemeProvider } from './contexts/ThemeContext';
import { ToastProvider } from './contexts/ToastContext';
import { ErrorBoundary } from './components/common/ErrorBoundary';
import { queryClient } from './lib/queryClient';
import { useAuth } from './contexts/AuthContext';
import { Login } from './pages/Login';
import { Dashboard } from './pages/Dashboard';
import { Users } from './pages/Users';
import Products from './pages/Products';
import { Orders } from './pages/Orders';
import { Reports } from './pages/Reports';
import { Notifications } from './pages/Notifications';
import { Layout } from './components/layout/Layout';
const ProtectedRoute = ({ children }: { children: any }) => {
const { user } = useAuth();
// Lazy load pages for better code splitting
const Login = lazy(() => import('./pages/Login').then(module => ({ default: module.Login })));
const Dashboard = lazy(() => import('./pages/Dashboard').then(module => ({ default: module.Dashboard })));
const Users = lazy(() => import('./pages/Users').then(module => ({ default: module.Users })));
const Reports = lazy(() => import('./pages/Reports').then(module => ({ default: module.Reports })));
const Notifications = lazy(() => import('./pages/Notifications').then(module => ({ default: module.Notifications })));
// Lazy load admin pages for better code splitting
// Roles Pages
const RolesListPage = lazy(() => import('./pages/roles/roles-list/RolesListPage'));
const RoleFormPage = lazy(() => import('./pages/roles/role-form/RoleFormPage'));
const RoleDetailPage = lazy(() => import('./pages/roles/role-detail/RoleDetailPage'));
const RolePermissionsPage = lazy(() => import('./pages/roles/role-permissions/RolePermissionsPage'));
// Admin Users Pages
const AdminUsersListPage = lazy(() => import('./pages/admin-users/admin-users-list/AdminUsersListPage'));
const AdminUserFormPage = lazy(() => import('./pages/admin-users/admin-user-form/AdminUserFormPage'));
const AdminUserDetailPage = lazy(() => import('./pages/admin-users/admin-user-detail/AdminUserDetailPage'));
// Permissions Pages
const PermissionsListPage = lazy(() => import('./pages/permissions/permissions-list/PermissionsListPage'));
const PermissionFormPage = lazy(() => import('./pages/permissions/permission-form/PermissionFormPage'));
// Product Options Pages
const ProductOptionsListPage = lazy(() => import('./pages/product-options/product-options-list/ProductOptionsListPage'));
const ProductOptionFormPage = lazy(() => import('./pages/product-options/product-option-form/ProductOptionFormPage'));
// Categories Pages
const CategoriesListPage = lazy(() => import('./pages/categories/categories-list/CategoriesListPage'));
const CategoryFormPage = lazy(() => import('./pages/categories/category-form/CategoryFormPage'));
// Discount Codes Pages
const DiscountCodesListPage = lazy(() => import('./pages/discount-codes/discount-codes-list/DiscountCodesListPage'));
const DiscountCodeFormPage = lazy(() => import('./pages/discount-codes/discount-code-form/DiscountCodeFormPage'));
// Orders Pages
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'));
const ProductDetailPage = lazy(() => import('./pages/products/product-detail/ProductDetailPage'));
// Landing Hero Page
const HeroSliderPage = lazy(() => import('./pages/landing-hero/HeroSliderPage'));
// Shipping Methods Pages
const ShippingMethodsListPage = lazy(() => import('./pages/shipping-methods/shipping-methods-list/ShippingMethodsListPage'));
const ShippingMethodFormPage = lazy(() => import('./pages/shipping-methods/shipping-method-form/ShippingMethodFormPage'));
const TicketsListPage = lazy(() => import('./pages/tickets/tickets-list/TicketsListPage'));
const TicketDetailPage = lazy(() => import('./pages/tickets/ticket-detail/TicketDetailPage'));
const TicketConfigPage = lazy(() => import('./pages/tickets/ticket-config/TicketConfigPage'));
const ContactUsListPage = lazy(() => import('./pages/contact-us/contact-us-list/ContactUsListPage'));
// Payment IPG Page
const IPGListPage = lazy(() => import('./pages/payment-ipg/ipg-list/IPGListPage'));
// Payment Card Page
const CardFormPage = lazy(() => import('./pages/payment-card/card-form/CardFormPage'));
// Wallet Page
const WalletListPage = lazy(() => import('./pages/wallet/wallet-list/WalletListPage'));
// Reports Pages
const DiscountUsageReportPage = lazy(() => import('./pages/reports/discount-statistics/discount-usage-report/DiscountUsageReportPage'));
const CustomerDiscountUsagePage = lazy(() => import('./pages/reports/discount-statistics/customer-discount-usage/CustomerDiscountUsagePage'));
const PaymentMethodsReportPage = lazy(() => import('./pages/reports/payment-statistics/payment-methods-report/PaymentMethodsReportPage'));
const ShipmentsByMethodReportPage = lazy(() => import('./pages/reports/shipment-statistics/shipments-by-method-report/ShipmentsByMethodReportPage'));
// Product Comments Page
const ProductCommentsListPage = lazy(() => import('./pages/products/comments/comments-list/ProductCommentsListPage'));
const ProtectedRoute = ({ children }: { children: React.ReactElement }) => {
const { user, isLoading } = useAuth();
if (isLoading) {
return (
<Layout />
);
}
return user ? children : <Navigate to="/login" replace />;
};
@ -32,16 +111,94 @@ const AppRoutes = () => {
}>
<Route index element={<Dashboard />} />
<Route path="users" element={<Users />} />
<Route path="products" element={<Products />} />
<Route path="orders" element={<Orders />} />
<Route path="products" element={<ProductsListPage />} />
<Route path="reports" element={<Reports />} />
<Route path="notifications" element={<Notifications />} />
{/* Roles Routes */}
<Route path="roles" element={<RolesListPage />} />
<Route path="roles/create" element={<RoleFormPage />} />
<Route path="roles/:id" element={<RoleDetailPage />} />
<Route path="roles/:id/edit" element={<RoleFormPage />} />
<Route path="roles/:id/permissions" element={<RolePermissionsPage />} />
{/* Admin Users Routes */}
<Route path="admin-users" element={<AdminUsersListPage />} />
<Route path="admin-users/create" element={<AdminUserFormPage />} />
<Route path="admin-users/:id" element={<AdminUserDetailPage />} />
<Route path="admin-users/:id/edit" element={<AdminUserFormPage />} />
{/* Permissions Routes */}
<Route path="permissions" element={<PermissionsListPage />} />
<Route path="permissions/create" element={<PermissionFormPage />} />
<Route path="permissions/:id/edit" element={<PermissionFormPage />} />
{/* Product Options Routes */}
<Route path="product-options" element={<ProductOptionsListPage />} />
<Route path="product-options/create" element={<ProductOptionFormPage />} />
<Route path="product-options/:id/edit" element={<ProductOptionFormPage />} />
{/* Categories Routes */}
<Route path="categories" element={<CategoriesListPage />} />
<Route path="categories/create" element={<CategoryFormPage />} />
<Route path="categories/:id/edit" element={<CategoryFormPage />} />
{/* Discount Codes Routes */}
<Route path="discount-codes" element={<DiscountCodesListPage />} />
<Route path="discount-codes/create" element={<DiscountCodeFormPage />} />
<Route path="discount-codes/:id/edit" element={<DiscountCodeFormPage />} />
{/* Orders Routes */}
<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 />} />
{/* Shipping Methods Routes */}
<Route path="shipping-methods" element={<ShippingMethodsListPage />} />
<Route path="shipping-methods/create" element={<ShippingMethodFormPage />} />
<Route path="shipping-methods/:id/edit" element={<ShippingMethodFormPage />} />
<Route path="shipping-methods/shipments-report" element={<ShipmentsByMethodReportPage />} />
<Route path="tickets" element={<TicketsListPage />} />
<Route path="tickets/config" element={<TicketConfigPage />} />
<Route path="tickets/:id" element={<TicketDetailPage />} />
<Route path="contact-us" element={<ContactUsListPage />} />
{/* Products Routes */}
<Route path="products/create" element={<ProductFormPage />} />
<Route path="products/:id" element={<ProductDetailPage />} />
<Route path="products/:id/edit" element={<ProductFormPage />} />
<Route path="products/comments" element={<ProductCommentsListPage />} />
{/* Payment IPG Route */}
<Route path="payment-ipg" element={<IPGListPage />} />
{/* Payment Card Route */}
<Route path="payment-card" element={<CardFormPage />} />
{/* Wallet Route */}
<Route path="wallet" element={<WalletListPage />} />
{/* Reports Routes */}
<Route path="reports/discount-usage" element={<DiscountUsageReportPage />} />
<Route path="reports/customer-discount-usage" element={<CustomerDiscountUsagePage />} />
<Route path="reports/payment-methods" element={<PaymentMethodsReportPage />} />
<Route path="reports/shipments-by-method" element={<ShipmentsByMethodReportPage />} />
</Route>
</Routes>
);
};
function App() {
const App = () => {
return (
<ErrorBoundary>
<QueryClientProvider client={queryClient}>
@ -49,7 +206,9 @@ function App() {
<ToastProvider>
<AuthProvider>
<Router>
<Suspense fallback={null}>
<AppRoutes />
</Suspense>
</Router>
</AuthProvider>
</ToastProvider>
@ -58,6 +217,6 @@ function App() {
</QueryClientProvider>
</ErrorBoundary>
);
}
};
export default App;

View File

@ -0,0 +1,92 @@
import React from 'react';
import ReactApexChart from 'react-apexcharts';
import type { ApexOptions } from 'apexcharts';
import { CardTitle } from '../ui/Typography';
import { englishToPersian, formatWithThousands } from '@/utils/numberUtils';
interface ApexAreaChartCardProps {
data: { name: string; value: number }[];
title?: string;
color?: string;
}
const formatNumber = (value: number | string) => {
const formatted = formatWithThousands(value);
return englishToPersian(formatted);
};
export const ApexAreaChartCard = ({ data, title, color = '#3b82f6' }: ApexAreaChartCardProps) => {
const categories = data.map((item) => item.name);
const series = [
{
name: title || '',
data: data.map((item) => item.value),
},
];
const options: ApexOptions = {
chart: {
type: 'area',
height: 250,
toolbar: { show: false },
zoom: { enabled: false },
fontFamily: 'inherit',
},
colors: [color],
dataLabels: { enabled: false },
stroke: {
curve: 'smooth',
width: 3,
},
fill: {
type: 'gradient',
gradient: {
shadeIntensity: 1,
opacityFrom: 0.45,
opacityTo: 0.05,
stops: [0, 90, 100],
},
},
grid: {
strokeDashArray: 4,
padding: { left: 12, right: 12 },
},
xaxis: {
categories,
labels: {
style: { fontSize: '11px' },
formatter: (value) => englishToPersian(value),
},
axisBorder: { show: true },
axisTicks: { show: true },
},
yaxis: {
labels: {
style: { fontSize: '11px' },
formatter: (value) => formatNumber(value),
minWidth: 70,
align: 'right',
offsetX: 0,
},
},
tooltip: {
y: {
formatter: (value) => formatNumber(value),
},
x: {
formatter: (value) => englishToPersian(value),
},
},
};
return (
<div className="card p-3 sm:p-4 lg:p-6">
{title && (
<CardTitle className="mb-3 sm:mb-4">
{title}
</CardTitle>
)}
<ReactApexChart options={options} series={series} type="area" height={250} />
</div>
);
};

View File

@ -0,0 +1,82 @@
import React from 'react';
import ReactApexChart from 'react-apexcharts';
import type { ApexOptions } from 'apexcharts';
import { CardTitle } from '../ui/Typography';
import { englishToPersian, formatWithThousands } from '@/utils/numberUtils';
interface ApexBarChartCardProps {
data: { name: string; value: number }[];
title?: string;
color?: string;
}
const formatNumber = (value: number | string) => {
const formatted = formatWithThousands(value);
return englishToPersian(formatted);
};
export const ApexBarChartCard = ({ data, title, color = '#3b82f6' }: ApexBarChartCardProps) => {
const categories = data.map((item) => item.name);
const series = [
{
name: title || '',
data: data.map((item) => item.value),
},
];
const options: ApexOptions = {
chart: {
type: 'bar',
height: 250,
toolbar: { show: false },
fontFamily: 'inherit',
},
colors: [color],
plotOptions: {
bar: {
borderRadius: 6,
columnWidth: '40%',
},
},
dataLabels: { enabled: false },
grid: {
strokeDashArray: 4,
padding: { left: 12, right: 12 },
},
xaxis: {
categories,
labels: {
style: { fontSize: '11px' },
formatter: (value) => englishToPersian(value),
},
},
yaxis: {
labels: {
style: { fontSize: '11px' },
formatter: (value) => formatNumber(value),
minWidth: 70,
align: 'right',
offsetX: 0,
},
},
tooltip: {
y: {
formatter: (value) => formatNumber(value),
},
x: {
formatter: (value) => englishToPersian(value),
},
},
};
return (
<div className="card p-3 sm:p-4 lg:p-6">
{title && (
<CardTitle className="mb-3 sm:mb-4">
{title}
</CardTitle>
)}
<ReactApexChart options={options} series={series} type="bar" height={250} />
</div>
);
};

View File

@ -0,0 +1,79 @@
import { AreaChart as RechartsAreaChart, Area, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer } from 'recharts';
import { CardTitle } from '../ui/Typography';
import { englishToPersian, formatWithThousands } from '@/utils/numberUtils';
interface AreaChartCardProps {
data: any[];
title?: string;
color?: string;
}
const formatNumber = (value: number | string) => {
const formatted = formatWithThousands(value);
return englishToPersian(formatted);
};
export const AreaChartCard = ({ data, title, color = '#3b82f6' }: AreaChartCardProps) => {
return (
<div className="card p-3 sm:p-4 lg:p-6">
{title && (
<CardTitle className="mb-3 sm:mb-4">
{title}
</CardTitle>
)}
<div className="w-full">
<ResponsiveContainer width="100%" height={250} minHeight={200}>
<RechartsAreaChart data={data} margin={{ top: 10, right: 10, left: 10, bottom: 10 }}>
<defs>
<linearGradient id={`areaFill-${color}`} x1="0" y1="0" x2="0" y2="1">
<stop offset="5%" stopColor={color} stopOpacity={0.5} />
<stop offset="95%" stopColor={color} stopOpacity={0.05} />
</linearGradient>
</defs>
<CartesianGrid strokeDasharray="4 4" className="stroke-gray-200 dark:stroke-gray-700" />
<XAxis
dataKey="name"
className="text-gray-600 dark:text-gray-400"
tick={{ fontSize: 11, fontFamily: 'inherit' }}
tickFormatter={(value) => englishToPersian(value)}
interval="preserveStartEnd"
minTickGap={16}
height={30}
/>
<YAxis
className="text-gray-600 dark:text-gray-400"
tick={{ fontSize: 11, fontFamily: 'inherit' }}
tickFormatter={(value) => formatNumber(value)}
width={72}
tickMargin={8}
tickCount={4}
allowDecimals={false}
/>
<Tooltip
contentStyle={{
backgroundColor: 'var(--toast-bg)',
color: 'var(--toast-color)',
border: 'none',
borderRadius: '8px',
boxShadow: '0 4px 6px -1px rgba(0, 0, 0, 0.1)',
fontSize: '12px',
fontFamily: 'inherit',
}}
formatter={(value: any) => formatNumber(value)}
labelFormatter={(label: any) => englishToPersian(label)}
/>
<Area
type="monotone"
dataKey="value"
stroke={color}
strokeWidth={3}
fill={`url(#areaFill-${color})`}
dot={false}
activeDot={{ r: 4 }}
/>
</RechartsAreaChart>
</ResponsiveContainer>
</div>
</div>
);
};

View File

@ -1,44 +1,71 @@
import { BarChart as RechartsBarChart, Bar, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer } from 'recharts';
import { ChartData } from '../../types';
import { CardTitle } from '../ui/Typography';
import { englishToPersian, formatWithThousands } from '@/utils/numberUtils';
const formatNumber = (value: number | string) => {
const formatted = formatWithThousands(value);
return englishToPersian(formatted);
};
interface BarChartProps {
data: ChartData[];
data: any[];
title?: string;
color?: string;
}
export const BarChart = ({ data, title, color = '#3b82f6' }: BarChartProps) => {
return (
<div className="card p-6">
<div className="card p-3 sm:p-4 lg:p-6">
{title && (
<h3 className="text-lg font-semibold text-gray-900 dark:text-gray-100 mb-4">
<CardTitle className="mb-3 sm:mb-4">
{title}
</h3>
</CardTitle>
)}
<ResponsiveContainer width="100%" height={300}>
<RechartsBarChart data={data}>
<CartesianGrid strokeDasharray="3 3" className="stroke-gray-300 dark:stroke-gray-600" />
<div className="w-full">
<ResponsiveContainer width="100%" height={250} minHeight={200}>
<RechartsBarChart data={data} margin={{ top: 10, right: 10, left: 10, bottom: 10 }}>
<defs>
<linearGradient id="barFill" x1="0" y1="0" x2="0" y2="1">
<stop offset="5%" stopColor={color} stopOpacity={0.9} />
<stop offset="95%" stopColor={color} stopOpacity={0.4} />
</linearGradient>
</defs>
<CartesianGrid strokeDasharray="4 4" className="stroke-gray-200 dark:stroke-gray-700" />
<XAxis
dataKey="name"
className="text-gray-600 dark:text-gray-400"
tick={{ fontSize: 12 }}
tick={{ fontSize: 11, fontFamily: 'inherit' }}
tickFormatter={(value) => englishToPersian(value)}
interval="preserveStartEnd"
height={40}
/>
<YAxis
className="text-gray-600 dark:text-gray-400"
tick={{ fontSize: 12 }}
tick={{ fontSize: 11, fontFamily: 'inherit' }}
tickFormatter={(value) => formatNumber(value)}
width={72}
tickMargin={8}
tickCount={4}
allowDecimals={false}
/>
<Tooltip
contentStyle={{
backgroundColor: 'var(--tooltip-bg)',
backgroundColor: 'var(--toast-bg)',
color: 'var(--toast-color)',
border: 'none',
borderRadius: '8px',
boxShadow: '0 4px 6px -1px rgba(0, 0, 0, 0.1)',
fontSize: '12px',
fontFamily: 'inherit',
}}
labelStyle={{ color: 'var(--tooltip-text)' }}
formatter={(value: any) => formatNumber(value)}
labelFormatter={(label: any) => englishToPersian(label)}
/>
<Bar dataKey="value" fill={color} radius={[4, 4, 0, 0]} />
<Bar dataKey="value" fill="url(#barFill)" radius={[8, 8, 0, 0]} barSize={28} />
</RechartsBarChart>
</ResponsiveContainer>
</div>
</div>
);
};

View File

@ -1,44 +1,72 @@
import { LineChart as RechartsLineChart, Line, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer } from 'recharts';
import { ChartData } from '../../types';
import { CardTitle } from '../ui/Typography';
import { englishToPersian, formatWithThousands } from '@/utils/numberUtils';
const formatNumber = (value: number | string) => {
const formatted = formatWithThousands(value);
return englishToPersian(formatted);
};
interface LineChartProps {
data: ChartData[];
data: any[];
title?: string;
color?: string;
}
export const LineChart = ({ data, title, color = '#10b981' }: LineChartProps) => {
return (
<div className="card p-6">
<div className="card p-3 sm:p-4 lg:p-6">
{title && (
<h3 className="text-lg font-semibold text-gray-900 dark:text-gray-100 mb-4">
<CardTitle className="mb-3 sm:mb-4">
{title}
</h3>
</CardTitle>
)}
<ResponsiveContainer width="100%" height={300}>
<RechartsLineChart data={data}>
<CartesianGrid strokeDasharray="3 3" className="stroke-gray-300 dark:stroke-gray-600" />
<div className="w-full">
<ResponsiveContainer width="100%" height={250} minHeight={200}>
<RechartsLineChart data={data} margin={{ top: 10, right: 10, left: 10, bottom: 10 }}>
<CartesianGrid strokeDasharray="4 4" className="stroke-gray-200 dark:stroke-gray-700" />
<XAxis
dataKey="name"
className="text-gray-600 dark:text-gray-400"
tick={{ fontSize: 12 }}
tick={{ fontSize: 11, fontFamily: 'inherit' }}
tickFormatter={(value) => englishToPersian(value)}
interval="preserveStartEnd"
height={40}
/>
<YAxis
className="text-gray-600 dark:text-gray-400"
tick={{ fontSize: 12 }}
tick={{ fontSize: 11, fontFamily: 'inherit' }}
tickFormatter={(value) => formatNumber(value)}
width={72}
tickMargin={8}
tickCount={4}
allowDecimals={false}
/>
<Tooltip
contentStyle={{
backgroundColor: 'var(--tooltip-bg)',
backgroundColor: 'var(--toast-bg)',
color: 'var(--toast-color)',
border: 'none',
borderRadius: '8px',
boxShadow: '0 4px 6px -1px rgba(0, 0, 0, 0.1)',
fontSize: '12px',
fontFamily: 'inherit',
}}
labelStyle={{ color: 'var(--tooltip-text)' }}
formatter={(value: any) => formatNumber(value)}
labelFormatter={(label: any) => englishToPersian(label)}
/>
<Line
type="monotone"
dataKey="value"
stroke={color}
strokeWidth={3}
dot={false}
activeDot={{ r: 5 }}
/>
<Line type="monotone" dataKey="value" stroke={color} strokeWidth={3} dot={{ r: 6 }} />
</RechartsLineChart>
</ResponsiveContainer>
</div>
</div>
);
};

View File

@ -1,8 +1,9 @@
import { PieChart as RechartsPieChart, Pie, Cell, ResponsiveContainer, Tooltip } from 'recharts';
import { ChartData } from '../../types';
import { PieChart as RechartsPieChart, Pie, Cell, Tooltip, ResponsiveContainer, Legend } from 'recharts';
import { CardTitle } from '../ui/Typography';
import { englishToPersian, formatWithThousands } from '@/utils/numberUtils';
interface PieChartProps {
data: ChartData[];
data: any[];
title?: string;
colors?: string[];
}
@ -10,40 +11,81 @@ interface PieChartProps {
const DEFAULT_COLORS = ['#3b82f6', '#10b981', '#f59e0b', '#ef4444', '#8b5cf6'];
export const PieChart = ({ data, title, colors = DEFAULT_COLORS }: PieChartProps) => {
// Custom legend component for left side
const CustomLegend = (props: any) => {
const { payload } = props;
return (
<div className="card p-6">
<div className="flex flex-col gap-2">
{payload.map((entry: any, index: number) => (
<div key={index} className="flex items-center gap-2">
<div
className="w-3 h-3 rounded-full flex-shrink-0 border border-white dark:border-gray-800"
style={{ backgroundColor: entry.color }}
/>
<span className="text-xs sm:text-sm text-gray-700 dark:text-gray-300 whitespace-nowrap">
<span className="font-medium">{entry.value}</span>: <span className="font-bold">{englishToPersian(Math.round(entry.payload.value))}%</span>
</span>
</div>
))}
</div>
);
};
return (
<div className="card p-3 sm:p-4 lg:p-6">
{title && (
<h3 className="text-lg font-semibold text-gray-900 dark:text-gray-100 mb-4">
<CardTitle className="mb-3 sm:mb-4 text-center">
{title}
</h3>
</CardTitle>
)}
<ResponsiveContainer width="100%" height={300}>
<div className="w-full flex items-center gap-4">
{/* Legend on the left */}
<div className="flex-shrink-0">
<CustomLegend payload={data.map((item, index) => ({
value: item.name,
color: colors[index % colors.length],
payload: item
}))} />
</div>
{/* Chart on the right */}
<div className="flex-1">
<ResponsiveContainer width="100%" height={280} minHeight={220}>
<RechartsPieChart>
<Pie
data={data}
cx="50%"
cy="50%"
labelLine={false}
label={({ name, percent }) => `${name} ${(percent * 100).toFixed(0)}%`}
outerRadius={80}
label={false}
outerRadius="75%"
innerRadius="35%"
fill="#8884d8"
dataKey="value"
stroke="#fff"
strokeWidth={3}
>
{data.map((entry, index) => (
{data.map((_, index) => (
<Cell key={`cell-${index}`} fill={colors[index % colors.length]} />
))}
</Pie>
<Tooltip
contentStyle={{
backgroundColor: 'var(--tooltip-bg)',
border: 'none',
backgroundColor: 'rgba(255, 255, 255, 0.95)',
color: '#1f2937',
border: '1px solid #e5e7eb',
borderRadius: '8px',
boxShadow: '0 4px 6px -1px rgba(0, 0, 0, 0.1)',
fontSize: '14px',
fontWeight: '500',
fontFamily: 'inherit',
}}
labelStyle={{ color: 'var(--tooltip-text)' }}
formatter={(value: any, name: any) => [`${englishToPersian(Math.round(value))}%`, name]}
/>
</RechartsPieChart>
</ResponsiveContainer>
</div>
</div>
</div>
);
};

View File

@ -0,0 +1,91 @@
import React from 'react';
import { Eye, Edit3, Trash2, LucideIcon } from 'lucide-react';
interface ActionButtonsProps {
onView?: () => void;
onEdit?: () => void;
onDelete?: () => void;
viewTitle?: string;
editTitle?: string;
deleteTitle?: string;
className?: string;
size?: 'sm' | 'md' | 'lg';
showLabels?: boolean;
}
const getSizeClasses = (size: 'sm' | 'md' | 'lg') => {
switch (size) {
case 'sm':
return 'h-3 w-3';
case 'md':
return 'h-4 w-4';
case 'lg':
return 'h-5 w-5';
default:
return 'h-4 w-4';
}
};
const getTextSizeClasses = (size: 'sm' | 'md' | 'lg') => {
switch (size) {
case 'sm':
return 'text-xs';
case 'md':
return 'text-xs';
case 'lg':
return 'text-sm';
default:
return 'text-xs';
}
};
export const ActionButtons: React.FC<ActionButtonsProps> = ({
onView,
onEdit,
onDelete,
viewTitle = 'مشاهده',
editTitle = 'ویرایش',
deleteTitle = 'حذف',
className = '',
size = 'md',
showLabels = false,
}) => {
const iconSize = getSizeClasses(size);
const textSize = getTextSizeClasses(size);
return (
<div className={`flex items-center gap-2 ${className}`}>
{onView && (
<button
onClick={onView}
className="text-blue-600 hover:text-blue-900 dark:text-blue-400 dark:hover:text-blue-300 flex items-center gap-1"
title={viewTitle}
>
<Eye className={iconSize} />
{showLabels && <span className={textSize}>{viewTitle}</span>}
</button>
)}
{onEdit && (
<button
onClick={onEdit}
className="text-indigo-600 hover:text-indigo-900 dark:text-indigo-400 dark:hover:text-indigo-300 flex items-center gap-1"
title={editTitle}
>
<Edit3 className={iconSize} />
{showLabels && <span className={textSize}>{editTitle}</span>}
</button>
)}
{onDelete && (
<button
onClick={onDelete}
className="text-red-600 hover:text-red-900 dark:text-red-400 dark:hover:text-red-300 flex items-center gap-1"
title={deleteTitle}
>
<Trash2 className={iconSize} />
{showLabels && <span className={textSize}>{deleteTitle}</span>}
</button>
)}
</div>
);
};

View File

@ -0,0 +1,61 @@
import React from 'react';
import { Modal } from '../ui/Modal';
import { Button } from '../ui/Button';
interface DeleteConfirmModalProps {
isOpen: boolean;
onClose: () => void;
onConfirm: () => void;
title?: string;
message?: string;
warningMessage?: string;
isLoading?: boolean;
itemName?: string;
}
export const DeleteConfirmModal: React.FC<DeleteConfirmModalProps> = ({
isOpen,
onClose,
onConfirm,
title = 'حذف',
message,
warningMessage,
isLoading = false,
itemName,
}) => {
const defaultMessage = itemName
? `آیا از حذف "${itemName}" اطمینان دارید؟ این عمل قابل بازگشت نیست.`
: 'آیا از حذف این مورد اطمینان دارید؟ این عمل قابل بازگشت نیست.';
return (
<Modal isOpen={isOpen} onClose={onClose} title={title}>
<div className="space-y-4">
<p className="text-gray-600 dark:text-gray-400">
{message || defaultMessage}
</p>
{warningMessage && (
<p className="text-sm text-red-600 dark:text-red-400">
{warningMessage}
</p>
)}
<div className="flex justify-end space-x-2 space-x-reverse">
<Button
variant="secondary"
onClick={onClose}
disabled={isLoading}
>
انصراف
</Button>
<Button
variant="danger"
onClick={onConfirm}
loading={isLoading}
>
حذف
</Button>
</div>
</div>
</Modal>
);
};

View File

@ -0,0 +1,46 @@
import React, { ReactNode } from 'react';
import { LucideIcon } from 'lucide-react';
import { Button } from '../ui/Button';
interface EmptyStateProps {
icon?: LucideIcon;
title: string;
description?: string;
actionLabel?: ReactNode;
onAction?: () => void;
className?: string;
}
export const EmptyState: React.FC<EmptyStateProps> = ({
icon: Icon,
title,
description,
actionLabel,
onAction,
className = '',
}) => {
return (
<div className={`text-center py-12 ${className}`}>
{Icon && (
<Icon className="mx-auto h-12 w-12 text-gray-400 dark:text-gray-500" />
)}
<h3 className="mt-2 text-sm font-medium text-gray-900 dark:text-gray-100">
{title}
</h3>
{description && (
<p className="mt-1 text-sm text-gray-500 dark:text-gray-400">
{description}
</p>
)}
{actionLabel && onAction && (
<div className="mt-6">
<Button onClick={onAction} className="flex items-center gap-2 mx-auto">
{actionLabel}
</Button>
</div>
)}
</div>
);
};

View File

@ -19,7 +19,7 @@ export class ErrorBoundary extends Component<Props, State> {
this.state = { hasError: false };
}
static getDerivedStateFromError(error: Error): State {
static getDerivedStateFromError(_: Error): State {
return { hasError: true };
}

View File

@ -0,0 +1,42 @@
import React, { ReactNode } from 'react';
interface FiltersSectionProps {
children: ReactNode;
isLoading?: boolean;
columns?: 1 | 2 | 3 | 4;
className?: string;
}
export const FiltersSection: React.FC<FiltersSectionProps> = ({
children,
isLoading = false,
columns = 4,
className = '',
}) => {
const gridCols = {
1: 'grid-cols-1',
2: 'grid-cols-1 md:grid-cols-2',
3: 'grid-cols-1 md:grid-cols-3',
4: 'grid-cols-1 md:grid-cols-4',
};
return (
<div className={`bg-white dark:bg-gray-800 shadow-sm border border-gray-200 dark:border-gray-700 rounded-lg p-4 ${className}`}>
{isLoading ? (
<div className={`grid ${gridCols[columns]} gap-4 animate-pulse`}>
{[...Array(columns)].map((_, i) => (
<div key={i}>
<div className="h-4 bg-gray-200 dark:bg-gray-700 rounded w-20 mb-2"></div>
<div className="h-10 bg-gray-200 dark:bg-gray-700 rounded-lg"></div>
</div>
))}
</div>
) : (
<div className={`grid ${gridCols[columns]} gap-4`}>
{children}
</div>
)}
</div>
);
};

View File

@ -0,0 +1,126 @@
import React from 'react';
interface ReportSkeletonProps {
summaryCardCount?: number;
tableColumnCount?: number;
tableRowCount?: number;
showMethodSummaries?: boolean;
showChart?: boolean;
showPaymentTypeCards?: boolean;
}
export const ReportSkeleton: React.FC<ReportSkeletonProps> = ({
summaryCardCount = 4,
tableColumnCount = 7,
tableRowCount = 5,
showMethodSummaries = false,
showChart = false,
showPaymentTypeCards = false,
}) => {
return (
<>
{/* Summary Cards Skeleton */}
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4 mb-6">
{[...Array(summaryCardCount)].map((_, i) => (
<div key={i} className="bg-white dark:bg-gray-800 shadow-sm border border-gray-200 dark:border-gray-700 rounded-lg p-4 animate-pulse">
<div className="flex items-center gap-3">
<div className="p-2 bg-gray-200 dark:bg-gray-700 rounded-lg w-12 h-12"></div>
<div className="flex-1">
<div className="h-4 bg-gray-200 dark:bg-gray-700 rounded w-24 mb-2"></div>
<div className="h-6 bg-gray-200 dark:bg-gray-700 rounded w-16"></div>
</div>
</div>
</div>
))}
</div>
{/* Method Summaries Skeleton */}
{showMethodSummaries && (
<div className="bg-white dark:bg-gray-800 shadow-sm border border-gray-200 dark:border-gray-700 rounded-lg p-4 mb-6 animate-pulse">
<div className="h-6 bg-gray-200 dark:bg-gray-700 rounded w-48 mb-4"></div>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
{[...Array(3)].map((_, i) => (
<div key={i} className="border border-gray-200 dark:border-gray-700 rounded-lg p-4">
<div className="h-5 bg-gray-200 dark:bg-gray-700 rounded w-32 mb-2"></div>
<div className="space-y-1">
{[...Array(6)].map((_, j) => (
<div key={j} className="flex justify-between">
<div className="h-4 bg-gray-200 dark:bg-gray-700 rounded w-20"></div>
<div className="h-4 bg-gray-200 dark:bg-gray-700 rounded w-16"></div>
</div>
))}
</div>
</div>
))}
</div>
</div>
)}
{/* Pie Chart and Total Amount Skeleton */}
{showChart && (
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6 mb-6">
<div className="lg:col-span-2 bg-white dark:bg-gray-800 shadow-sm border border-gray-200 dark:border-gray-700 rounded-lg p-6 animate-pulse">
<div className="h-6 bg-gray-200 dark:bg-gray-700 rounded w-48 mb-6"></div>
<div className="h-64 bg-gray-200 dark:bg-gray-700 rounded"></div>
</div>
<div className="bg-white dark:bg-gray-800 shadow-sm border border-gray-200 dark:border-gray-700 rounded-lg p-6 animate-pulse">
<div className="h-16 w-16 bg-gray-200 dark:bg-gray-700 rounded-full mx-auto mb-4"></div>
<div className="h-4 bg-gray-200 dark:bg-gray-700 rounded w-24 mx-auto mb-2"></div>
<div className="h-8 bg-gray-200 dark:bg-gray-700 rounded w-40 mx-auto"></div>
</div>
</div>
)}
{/* Payment Type Cards Skeleton */}
{showPaymentTypeCards && (
<div className="bg-white dark:bg-gray-800 shadow-sm border border-gray-200 dark:border-gray-700 rounded-lg p-6 mb-6 animate-pulse">
<div className="h-6 bg-gray-200 dark:bg-gray-700 rounded w-48 mb-6"></div>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-5 gap-4">
{[...Array(5)].map((_, i) => (
<div key={i} className="border-2 border-gray-200 dark:border-gray-700 rounded-lg p-5 bg-gray-50 dark:bg-gray-700/50">
<div className="h-5 bg-gray-200 dark:bg-gray-600 rounded w-32 mb-4"></div>
<div className="space-y-2.5">
{[...Array(5)].map((_, j) => (
<div key={j} className="flex justify-between">
<div className="h-4 bg-gray-200 dark:bg-gray-600 rounded w-16"></div>
<div className="h-4 bg-gray-200 dark:bg-gray-600 rounded w-12"></div>
</div>
))}
</div>
</div>
))}
</div>
</div>
)}
{/* Table Skeleton */}
<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>
{[...Array(tableColumnCount)].map((_, i) => (
<th key={i} className="px-6 py-3 text-right text-xs font-medium text-gray-500 dark:text-gray-300 uppercase tracking-wider">
<div className="h-4 bg-gray-200 dark:bg-gray-600 rounded w-20"></div>
</th>
))}
</tr>
</thead>
<tbody className="bg-white dark:bg-gray-800 divide-y divide-gray-200 dark:divide-gray-700">
{[...Array(tableRowCount)].map((_, i) => (
<tr key={i} className="animate-pulse">
{[...Array(tableColumnCount)].map((_, j) => (
<td key={j} className="px-6 py-4 whitespace-nowrap">
<div className="h-4 bg-gray-200 dark:bg-gray-600 rounded"></div>
</td>
))}
</tr>
))}
</tbody>
</table>
</div>
</div>
</>
);
};

View File

@ -0,0 +1,80 @@
import React from 'react';
interface TableSkeletonProps {
columns?: number;
rows?: number;
showMobileCards?: boolean;
className?: string;
}
export const TableSkeleton: React.FC<TableSkeletonProps> = ({
columns = 5,
rows = 5,
showMobileCards = true,
className = '',
}) => {
return (
<div className={`bg-white dark:bg-gray-800 shadow-sm border border-gray-200 dark:border-gray-700 rounded-lg overflow-hidden ${className}`}>
{/* Desktop Table Skeleton */}
<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>
{[...Array(columns)].map((_, i) => (
<th
key={i}
className="px-6 py-3 text-right text-xs font-medium text-gray-500 dark:text-gray-300 uppercase tracking-wider"
>
<div className="h-4 bg-gray-200 dark:bg-gray-600 rounded w-24 animate-pulse"></div>
</th>
))}
</tr>
</thead>
<tbody className="bg-white dark:bg-gray-800 divide-y divide-gray-200 dark:divide-gray-700">
{[...Array(rows)].map((_, rowIndex) => (
<tr key={rowIndex}>
{[...Array(columns)].map((_, colIndex) => (
<td key={colIndex} className="px-6 py-4 whitespace-nowrap">
{colIndex === columns - 1 ? (
<div className="flex gap-2">
<div className="h-8 w-8 bg-gray-200 dark:bg-gray-600 rounded animate-pulse"></div>
<div className="h-8 w-8 bg-gray-200 dark:bg-gray-600 rounded animate-pulse"></div>
</div>
) : (
<div className="h-4 bg-gray-200 dark:bg-gray-600 rounded animate-pulse w-32"></div>
)}
</td>
))}
</tr>
))}
</tbody>
</table>
</div>
</div>
{/* Mobile Cards Skeleton */}
{showMobileCards && (
<div className="md:hidden p-4 space-y-4">
{[...Array(Math.min(rows, 3))].map((_, index) => (
<div
key={index}
className="border border-gray-200 dark:border-gray-700 rounded-lg p-4 animate-pulse"
>
<div className="space-y-3">
<div className="h-5 bg-gray-300 dark:bg-gray-600 rounded w-3/4"></div>
<div className="h-4 bg-gray-300 dark:bg-gray-600 rounded w-full"></div>
<div className="h-3 bg-gray-300 dark:bg-gray-600 rounded w-1/3"></div>
<div className="flex gap-2 pt-2">
<div className="h-8 w-8 bg-gray-300 dark:bg-gray-600 rounded"></div>
<div className="h-8 w-8 bg-gray-300 dark:bg-gray-600 rounded"></div>
</div>
</div>
</div>
))}
</div>
)}
</div>
);
};

View File

@ -1,4 +1,5 @@
import { TrendingUp, TrendingDown } from 'lucide-react';
import { StatValue, StatLabel } from '../ui/Typography';
interface StatsCardProps {
title: string;
@ -27,31 +28,31 @@ export const StatsCard = ({
const isNegative = change && change < 0;
return (
<div className="card p-6 animate-fade-in">
<div className="card p-4 sm:p-5 lg:p-6 animate-fade-in">
<div className="flex items-center">
<div className="flex-shrink-0">
<div className={`p-3 rounded-lg ${colorClasses[color as keyof typeof colorClasses] || colorClasses.blue}`}>
<Icon className="h-6 w-6 text-white" />
<div className={`p-3 sm:p-4 rounded-xl ${colorClasses[color as keyof typeof colorClasses] || colorClasses.blue} shadow-sm`}>
<Icon className="h-5 w-5 sm:h-6 sm:w-6 text-white" />
</div>
</div>
<div className="mr-5 w-0 flex-1">
<div className="mr-3 sm:mr-5 w-0 flex-1 min-w-0">
<dl>
<dt className="text-sm font-medium text-gray-500 dark:text-gray-400 truncate">
<StatLabel className="truncate">
{title}
</dt>
</StatLabel>
<dd className="flex items-baseline">
<div className="text-2xl font-semibold text-gray-900 dark:text-gray-100">
<StatValue className="truncate">
{typeof value === 'number' ? value.toLocaleString() : value}
</div>
</StatValue>
{change !== undefined && (
<div className={`mr-2 flex items-baseline text-sm font-semibold ${isPositive ? 'text-green-600' : isNegative ? 'text-red-600' : 'text-gray-500'
<div className={`mr-1 sm:mr-2 flex items-baseline text-xs sm:text-sm font-semibold ${isPositive ? 'text-green-600' : isNegative ? 'text-red-600' : 'text-gray-500'
}`}>
{isPositive && <TrendingUp className="h-4 w-4 flex-shrink-0 self-center ml-1" />}
{isNegative && <TrendingDown className="h-4 w-4 flex-shrink-0 self-center ml-1" />}
{isPositive && <TrendingUp className="h-3 w-3 sm:h-4 sm:w-4 flex-shrink-0 self-center ml-1" />}
{isNegative && <TrendingDown className="h-3 w-3 sm:h-4 sm:w-4 flex-shrink-0 self-center ml-1" />}
<span className="sr-only">
{isPositive ? 'افزایش' : 'کاهش'}
</span>
{Math.abs(change)}%
<span className="truncate">{Math.abs(change)}%</span>
</div>
)}
</dd>

View File

@ -0,0 +1,43 @@
import React from 'react';
import { Button } from '../ui/Button';
interface FormActionsProps {
onCancel?: () => void;
cancelLabel?: string;
submitLabel?: string;
isLoading?: boolean;
isDisabled?: boolean;
className?: string;
}
export const FormActions: React.FC<FormActionsProps> = ({
onCancel,
cancelLabel = 'انصراف',
submitLabel = 'ذخیره',
isLoading = false,
isDisabled = false,
className = '',
}) => {
return (
<div className={`flex justify-end space-x-4 space-x-reverse pt-6 border-t border-gray-200 dark:border-gray-600 ${className}`}>
{onCancel && (
<Button
type="button"
variant="secondary"
onClick={onCancel}
disabled={isLoading}
>
{cancelLabel}
</Button>
)}
<Button
type="submit"
loading={isLoading}
disabled={isDisabled || isLoading}
>
{submitLabel}
</Button>
</div>
);
};

View File

@ -0,0 +1,26 @@
import React, { ReactNode } from 'react';
import { SectionTitle } from '../ui/Typography';
interface FormSectionProps {
title: string;
children: ReactNode;
className?: string;
titleClassName?: string;
}
export const FormSection: React.FC<FormSectionProps> = ({
title,
children,
className = '',
titleClassName = '',
}) => {
return (
<div className={className}>
<SectionTitle className={`mb-4 ${titleClassName}`}>
{title}
</SectionTitle>
{children}
</div>
);
};

View File

@ -1,126 +1,82 @@
import { useForm } from 'react-hook-form';
import { yupResolver } from '@hookform/resolvers/yup';
import { User, Phone, Mail, UserCircle } from 'lucide-react';
import { Input } from '../ui/Input';
import * as yup from 'yup';
import { Button } from '../ui/Button';
import { userSchema, UserFormData } from '../../utils/validationSchemas';
import { Input } from '../ui/Input';
import { UserFormData } from '../../utils/validationSchemas';
const userSchema = yup.object({
name: yup.string().required('نام الزامی است'),
email: yup.string().email('ایمیل معتبر نیست').required('ایمیل الزامی است'),
phone: yup.string().required('شماره تلفن الزامی است'),
role: yup.string().required('نقش الزامی است'),
password: yup.string().notRequired(),
});
interface UserFormProps {
initialData?: Partial<UserFormData>;
onSubmit: (data: UserFormData) => void;
onCancel: () => void;
defaultValues?: Partial<UserFormData>;
initialData?: any;
onCancel?: () => void;
loading?: boolean;
isEdit?: boolean;
isLoading?: boolean;
}
export const UserForm = ({
initialData,
onSubmit,
onCancel,
loading = false,
isEdit = false
}: UserFormProps) => {
export const UserForm = ({ onSubmit, defaultValues, initialData, onCancel, loading, isEdit, isLoading }: UserFormProps) => {
const {
register,
handleSubmit,
formState: { errors, isValid },
} = useForm<UserFormData>({
formState: { errors, isValid }
} = useForm({
resolver: yupResolver(userSchema),
mode: 'onChange',
defaultValues: initialData,
});
defaultValues: defaultValues || initialData,
mode: 'onChange'
}) as any;
return (
<div className="card p-6">
<div className="space-y-6">
<div className="mb-6">
<h2 className="text-xl font-bold text-gray-900 dark:text-gray-100">
{isEdit ? 'ویرایش کاربر' : 'افزودن کاربر جدید'}
اطلاعات کاربر
</h2>
<p className="text-gray-600 dark:text-gray-400 mt-1">
اطلاعات کاربر را وارد کنید
لطفا اطلاعات کاربر را کامل کنید
</p>
</div>
<form onSubmit={handleSubmit(onSubmit)} className="space-y-6">
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
<form onSubmit={handleSubmit(onSubmit as any)} className="space-y-4">
<Input
label="نام و نام خانوادگی"
placeholder="علی احمدی"
icon={User}
error={errors.name?.message}
label="نام"
{...register('name')}
error={errors.name?.message}
placeholder="نام کاربر"
/>
<Input
label="ایمیل"
type="email"
placeholder="ali@example.com"
icon={Mail}
error={errors.email?.message}
{...register('email')}
error={errors.email?.message}
placeholder="example@email.com"
/>
<Input
label="شماره تلفن"
label="تلفن"
type="tel"
placeholder="09123456789"
icon={Phone}
error={errors.phone?.message}
{...register('phone')}
error={errors.phone?.message}
placeholder="09xxxxxxxxx"
/>
<div className="space-y-1">
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300">
نقش
</label>
<div className="relative">
<div className="absolute inset-y-0 right-0 pr-3 flex items-center pointer-events-none">
<UserCircle className="h-5 w-5 text-gray-400" />
</div>
<select
className={`input pr-10 ${errors.role ? 'border-red-500 dark:border-red-500 focus:ring-red-500' : ''
}`}
{...register('role')}
>
<option value="">انتخاب کنید</option>
<option value="کاربر">کاربر</option>
<option value="مدیر">مدیر</option>
<option value="ادمین">ادمین</option>
</select>
</div>
{errors.role && (
<p className="text-sm text-red-600 dark:text-red-400">
{errors.role.message}
</p>
)}
</div>
</div>
{!isEdit && (
<Input
label="رمز عبور"
type="password"
placeholder="حداقل ۶ کاراکتر"
error={errors.password?.message}
{...register('password')}
/>
)}
<div className="flex items-center justify-end space-x-4 pt-6 border-t border-gray-200 dark:border-gray-700">
<Button
type="button"
variant="secondary"
onClick={onCancel}
disabled={loading}
>
انصراف
</Button>
<div className="pt-4">
<Button
type="submit"
loading={loading}
disabled={!isValid}
disabled={!isValid || isLoading}
className="w-full"
>
{isEdit ? 'ویرایش' : 'افزودن'}
{isLoading ? 'در حال ذخیره...' : 'ذخیره'}
</Button>
</div>
</form>

View File

@ -2,7 +2,7 @@ import { Menu, Sun, Moon, Bell, User, LogOut } from 'lucide-react';
import { useState } from 'react';
import { useAuth } from '../../contexts/AuthContext';
import { useTheme } from '../../contexts/ThemeContext';
import { Button } from '../ui/Button';
import { SectionTitle } from '../ui/Typography';
interface HeaderProps {
onMenuClick: () => void;
@ -14,21 +14,19 @@ export const Header = ({ onMenuClick }: HeaderProps) => {
const [showUserMenu, setShowUserMenu] = useState(false);
return (
<header className="bg-white dark:bg-gray-800 shadow-sm border-b border-gray-200 dark:border-gray-700">
<div className="flex items-center justify-between px-4 py-3">
<div className="flex items-center">
<header className="bg-white dark:bg-gray-800 shadow-md border-b border-gray-200 dark:border-gray-700">
<div className="flex items-center justify-between px-4 sm:px-6 lg:px-8 py-4">
<div className="flex items-center space-x-4 space-x-reverse">
<button
onClick={onMenuClick}
className="p-2 rounded-lg hover:bg-gray-100 dark:hover:bg-gray-700 lg:hidden"
>
<Menu className="h-5 w-5 text-gray-600 dark:text-gray-400" />
</button>
<h1 className="mr-4 text-lg font-semibold text-gray-900 dark:text-gray-100">
خوش آمدید
</h1>
<SectionTitle>خوش آمدید</SectionTitle>
</div>
<div className="flex items-center space-x-4">
<div className="flex items-center space-x-2 space-x-reverse">
<button
onClick={toggleTheme}
className="p-2 rounded-lg hover:bg-gray-100 dark:hover:bg-gray-700 transition-colors"
@ -48,15 +46,15 @@ export const Header = ({ onMenuClick }: HeaderProps) => {
<div className="relative">
<button
onClick={() => setShowUserMenu(!showUserMenu)}
className="flex items-center space-x-2 p-2 rounded-lg hover:bg-gray-100 dark:hover:bg-gray-700 transition-colors"
className="flex items-center space-x-2 space-x-reverse p-2 rounded-lg hover:bg-gray-100 dark:hover:bg-gray-700 transition-colors"
>
<div className="w-8 h-8 bg-primary-600 rounded-full flex items-center justify-center">
<span className="text-white text-sm font-medium">
{user?.name?.charAt(0) || 'A'}
{user?.first_name?.charAt(0) || 'A'}
</span>
</div>
<span className="text-sm font-medium text-gray-700 dark:text-gray-300 hidden md:block">
{user?.name || 'کاربر'}
<span className="text-sm font-medium text-gray-700 dark:text-gray-300 hidden sm:block">
{user?.first_name} {user?.last_name}
</span>
</button>
@ -65,10 +63,10 @@ export const Header = ({ onMenuClick }: HeaderProps) => {
<div className="py-1">
<div className="px-4 py-2 border-b border-gray-200 dark:border-gray-700">
<p className="text-sm font-medium text-gray-900 dark:text-gray-100">
{user?.name}
{user?.first_name} {user?.last_name}
</p>
<p className="text-xs text-gray-500 dark:text-gray-400">
{user?.email}
{user?.username}
</p>
</div>
<button className="w-full text-right px-4 py-2 text-sm text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700 flex items-center">

View File

@ -1,23 +1,46 @@
import { useState } from 'react';
import { Suspense, useState } from 'react';
import { Outlet } from 'react-router-dom';
import { Sidebar } from './Sidebar';
import { Header } from './Header';
const ContentSkeleton = () => (
<div className="space-y-6">
<div className="h-10 w-1/3 rounded-lg bg-gray-200 dark:bg-gray-800 animate-pulse" />
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
{[...Array(3)].map((_, index) => (
<div
key={index}
className="h-36 rounded-lg bg-white dark:bg-gray-800 shadow-sm"
>
<div className="h-full w-full rounded-lg bg-gray-200 dark:bg-gray-700 animate-pulse" />
</div>
))}
</div>
<div className="h-96 rounded-lg bg-white dark:bg-gray-800 shadow-sm">
<div className="h-full w-full rounded-lg bg-gray-200 dark:bg-gray-700 animate-pulse" />
</div>
</div>
);
export const Layout = () => {
const [sidebarOpen, setSidebarOpen] = useState(false);
return (
<div className="flex h-screen bg-gray-50 dark:bg-gray-900">
<div className="flex h-screen bg-gray-50 dark:bg-gray-900 overflow-hidden">
<Sidebar
isOpen={sidebarOpen}
onClose={() => setSidebarOpen(false)}
/>
<div className="flex-1 flex flex-col overflow-hidden">
<div className="flex-1 flex flex-col min-w-0 overflow-hidden">
<Header onMenuClick={() => setSidebarOpen(true)} />
<main className="flex-1 overflow-y-auto bg-gray-50 dark:bg-gray-900">
<div className="min-h-full py-6 px-4 sm:px-6 lg:px-8">
<Suspense fallback={<ContentSkeleton />}>
<Outlet />
</Suspense>
</div>
</main>
</div>
</div>

View File

@ -0,0 +1,40 @@
import React, { ReactNode } from 'react';
import { LucideIcon } from 'lucide-react';
interface PageHeaderProps {
title: string;
subtitle?: string;
icon?: LucideIcon;
actions?: ReactNode;
className?: string;
}
export const PageHeader: React.FC<PageHeaderProps> = ({
title,
subtitle,
icon: Icon,
actions,
className = '',
}) => {
return (
<div className={`flex flex-col space-y-3 sm:flex-row sm:items-center sm:justify-between sm:space-y-0 ${className}`}>
<div>
<h1 className="text-2xl font-bold text-gray-900 dark:text-gray-100 flex items-center gap-2">
{Icon && <Icon className="h-6 w-6" />}
{title}
</h1>
{subtitle && (
<p className="text-gray-600 dark:text-gray-400 mt-1">
{subtitle}
</p>
)}
</div>
{actions && (
<div className="flex-shrink-0">
{actions}
</div>
)}
</div>
);
};

View File

@ -1,62 +1,186 @@
import { useState } from 'react';
import { NavLink } from 'react-router-dom';
import React from 'react';
import { NavLink, useLocation } from 'react-router-dom';
import {
LayoutDashboard,
Users,
ShoppingBag,
Home,
Settings,
Shield,
UserCog,
Key,
LogOut,
ChevronDown,
ChevronLeft,
Package,
FolderOpen,
Sliders,
BadgePercent,
ShoppingCart,
FileText,
Bell,
Menu,
Users,
Truck,
X,
ChevronDown
MessageSquare,
CreditCard,
Wallet,
BarChart3,
FileText,
TrendingUp
} from 'lucide-react';
import { useAuth } from '../../contexts/AuthContext';
import { PermissionWrapper } from '../common/PermissionWrapper';
import { MenuItem } from '../../types';
import { SectionTitle, SmallText } from '../ui/Typography';
interface MenuItem {
title: string;
icon: any;
path?: string;
permission?: number;
children?: MenuItem[];
exact?: boolean;
}
const menuItems: MenuItem[] = [
{
id: 'dashboard',
label: 'داشبورد',
icon: LayoutDashboard,
title: 'داشبورد',
icon: Home,
path: '/',
},
{
id: 'users',
label: 'کاربران',
icon: Users,
path: '/users',
permission: 10,
},
{
id: 'products',
label: 'محصولات',
icon: ShoppingBag,
path: '/products',
permission: 15,
},
{
id: 'orders',
label: 'سفارشات',
title: 'سفارشات',
icon: ShoppingCart,
path: '/orders',
permission: 20,
},
{
id: 'reports',
label: 'گزارش‌ها',
title: 'مدیریت کاربران',
icon: Users,
path: '/users-admin',
},
{
title: 'کدهای تخفیف',
icon: BadgePercent,
path: '/discount-codes',
},
{
title: 'تیکت‌ها',
icon: MessageSquare,
children: [
{
title: 'لیست تیکت‌ها',
icon: MessageSquare,
path: '/tickets',
exact: true,
},
{
title: 'تنظیمات تیکت',
icon: Sliders,
path: '/tickets/config',
},
]
},
{
title: 'پیام‌های تماس با ما',
icon: FileText,
path: '/reports',
permission: 25,
path: '/contact-us',
},
{
id: 'notifications',
label: 'اعلانات',
icon: Bell,
path: '/notifications',
permission: 30,
title: 'مدیریت محصولات',
icon: Package,
children: [
{
title: 'محصولات',
icon: Package,
path: '/products',
},
{
title: 'دسته‌بندی‌ها',
icon: FolderOpen,
path: '/categories',
},
{
title: 'گزینه‌های محصول',
icon: Sliders,
path: '/product-options',
},
{
title: 'نظرات محصولات',
icon: MessageSquare,
path: '/products/comments',
},
]
},
{
title: 'گزارش‌ها',
icon: BarChart3,
children: [
{
title: 'گزارش کدهای تخفیف',
icon: BadgePercent,
path: '/reports/discount-usage',
},
{
title: 'گزارش کاربر و کد تخفیف',
icon: Users,
path: '/reports/customer-discount-usage',
},
{
title: 'گزارش روش‌های پرداخت',
icon: CreditCard,
path: '/reports/payment-methods',
},
{
title: 'گزارش ارسال‌ها',
icon: Truck,
path: '/reports/shipments-by-method',
},
]
},
{
title: 'مدیریت سیستم',
icon: Settings,
children: [
{
title: 'نقش‌ها',
icon: Shield,
path: '/roles',
permission: 22,
},
{
title: 'کاربران ادمین',
icon: UserCog,
path: '/admin-users',
permission: 22,
},
{
title: 'دسترسی‌ها',
icon: Key,
path: '/permissions',
permission: 22,
},
{
title: 'اسلایدر لندینگ',
icon: Sliders,
path: '/landing-hero',
},
{
title: 'روش‌های ارسال',
icon: Truck,
path: '/shipping-methods',
},
{
title: 'درگاه‌های پرداخت',
icon: CreditCard,
path: '/payment-ipg',
},
{
title: 'پرداخت کارت به کارت',
icon: CreditCard,
path: '/payment-card',
},
{
title: 'مدیریت کیف پول',
icon: Wallet,
path: '/wallet',
},
]
}
];
interface SidebarProps {
@ -65,98 +189,129 @@ interface SidebarProps {
}
export const Sidebar = ({ isOpen, onClose }: SidebarProps) => {
const { user } = useAuth();
const [expandedItems, setExpandedItems] = useState<string[]>([]);
const { user, logout } = useAuth();
const location = useLocation();
const [expandedItems, setExpandedItems] = React.useState<string[]>(() => {
// Load from localStorage on mount
const saved = localStorage.getItem('sidebar_expanded_items');
return saved ? JSON.parse(saved) : [];
});
const toggleExpanded = (itemId: string) => {
setExpandedItems(prev =>
prev.includes(itemId)
? prev.filter(id => id !== itemId)
: [...prev, itemId]
);
// Auto-expand menu items based on current route
React.useEffect(() => {
const currentPath = location.pathname;
setExpandedItems(prev => {
const itemsToExpand: string[] = [];
menuItems.forEach(item => {
if (item.children) {
// Check if any child matches current path
const hasActiveChild = item.children.some(child => {
if (child.path) {
if (child.exact) {
return currentPath === child.path;
}
return currentPath.startsWith(child.path);
}
return false;
});
if (hasActiveChild && !prev.includes(item.title)) {
itemsToExpand.push(item.title);
}
}
});
if (itemsToExpand.length > 0) {
return [...prev, ...itemsToExpand];
}
return prev;
});
}, [location.pathname]);
React.useEffect(() => {
// Save to localStorage whenever expandedItems changes
localStorage.setItem('sidebar_expanded_items', JSON.stringify(expandedItems));
}, [expandedItems]);
const toggleExpanded = (title: string) => {
setExpandedItems(prev => {
const newItems = prev.includes(title)
? prev.filter(item => item !== title)
: [...prev, title];
return newItems;
});
};
const renderMenuItem = (item: MenuItem) => {
const renderMenuItem = (item: MenuItem, depth = 0) => {
const hasChildren = item.children && item.children.length > 0;
const isExpanded = expandedItems.includes(item.id);
const isExpanded = expandedItems.includes(item.title);
const paddingLeft = depth * 16;
if (hasChildren) {
return (
<div key={item.title} className="space-y-1">
<button
onClick={() => toggleExpanded(item.title)}
className={`w-full flex items-center px-4 py-3 text-sm font-medium rounded-xl transition-all duration-200
text-gray-600 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700 hover:shadow-sm`}
style={{ paddingLeft: `${paddingLeft + 16}px` }}
>
<item.icon className="ml-3 h-5 w-5" />
<span className="flex-1 text-right">{item.title}</span>
{isExpanded ? (
<ChevronDown className="h-4 w-4" />
) : (
<ChevronLeft className="h-4 w-4" />
)}
</button>
{isExpanded && item.children && (
<div className="space-y-1">
{item.children.map(child => renderMenuItem(child, depth + 1))}
</div>
)}
</div>
);
}
const menuContent = (
<>
<div
className={`flex items-center justify-between px-4 py-3 text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700 rounded-lg transition-colors ${hasChildren ? 'cursor-pointer' : ''
}`}
onClick={hasChildren ? () => toggleExpanded(item.id) : undefined}
>
<div className="flex items-center">
<item.icon className="h-5 w-5 ml-3" />
<span className="font-medium">{item.label}</span>
</div>
{hasChildren && (
<ChevronDown
className={`h-4 w-4 transition-transform ${isExpanded ? 'rotate-180' : ''}`}
/>
)}
</div>
{hasChildren && isExpanded && (
<div className="mr-8 mt-1 space-y-1">
{item.children?.map(child => (
<div key={child.id}>
{child.permission ? (
<PermissionWrapper permission={child.permission}>
<NavLink
to={child.path}
to={item.path!}
end={item.exact}
onClick={() => {
// Close mobile menu when clicking a link
if (window.innerWidth < 1024) {
onClose();
}
}}
className={({ isActive }) =>
`block px-4 py-2 text-sm text-gray-600 dark:text-gray-400 hover:bg-gray-100 dark:hover:bg-gray-700 rounded-lg transition-colors ${isActive ? 'bg-primary-100 dark:bg-primary-900 text-primary-700 dark:text-primary-300' : ''
`w-full flex items-center px-4 py-3 text-sm font-medium rounded-xl transition-all duration-200 ${isActive
? 'bg-primary-50 dark:bg-primary-900 text-primary-600 dark:text-primary-400 shadow-sm'
: 'text-gray-600 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700 hover:text-gray-900 dark:hover:text-white hover:shadow-sm'
}`
}
onClick={onClose}
style={{ paddingLeft: `${paddingLeft + 16}px` }}
>
{child.label}
<item.icon className="ml-3 h-5 w-5" />
{item.title}
</NavLink>
</PermissionWrapper>
) : (
<NavLink
to={child.path}
className={({ isActive }) =>
`block px-4 py-2 text-sm text-gray-600 dark:text-gray-400 hover:bg-gray-100 dark:hover:bg-gray-700 rounded-lg transition-colors ${isActive ? 'bg-primary-100 dark:bg-primary-900 text-primary-700 dark:text-primary-300' : ''
}`
}
onClick={onClose}
>
{child.label}
</NavLink>
)}
</div>
))}
</div>
)}
</>
);
if (!hasChildren) {
if (item.permission) {
return (
<NavLink
to={item.path}
className={({ isActive }) =>
`flex items-center px-4 py-3 rounded-lg transition-colors ${isActive
? 'text-primary-700 dark:text-primary-300 bg-primary-100 dark:bg-primary-900'
: 'text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700'
}`
}
onClick={onClose}
>
<item.icon className="h-5 w-5 ml-3" />
<span className="font-medium">{item.label}</span>
</NavLink>
<PermissionWrapper key={item.title} permission={item.permission}>
{menuContent}
</PermissionWrapper>
);
}
return <div>{menuContent}</div>;
return <div key={item.title}>{menuContent}</div>;
};
return (
<>
{/* Mobile overlay */}
{isOpen && (
<div
className="fixed inset-0 bg-black bg-opacity-50 z-40 lg:hidden"
@ -164,58 +319,62 @@ export const Sidebar = ({ isOpen, onClose }: SidebarProps) => {
/>
)}
{/* Sidebar */}
<div className={`
fixed top-0 right-0 h-full w-64 bg-white dark:bg-gray-800 shadow-lg z-50 transform transition-transform duration-300 ease-in-out
${isOpen ? 'translate-x-0' : 'translate-x-full'}
lg:relative lg:translate-x-0
fixed lg:static inset-y-0 right-0 z-50
w-64 transform transition-transform duration-300 ease-in-out
lg:translate-x-0 lg:block
${isOpen ? 'translate-x-0' : 'translate-x-full lg:translate-x-0'}
flex flex-col h-screen bg-white dark:bg-gray-800 border-l border-gray-200 dark:border-gray-700 shadow-lg lg:shadow-none
`}>
<div className="flex items-center justify-between p-4 border-b border-gray-200 dark:border-gray-700">
<div className="flex items-center">
<div className="w-8 h-8 bg-primary-600 rounded-lg flex items-center justify-center">
<LayoutDashboard className="h-5 w-5 text-white" />
</div>
<span className="mr-3 text-xl font-bold text-gray-900 dark:text-gray-100">
{/* Mobile close button */}
<div className="lg:hidden flex justify-between items-center p-4 border-b border-gray-200 dark:border-gray-700 flex-shrink-0">
<SectionTitle>
پنل مدیریت
</span>
</div>
</SectionTitle>
<button
onClick={onClose}
className="p-1 rounded-lg hover:bg-gray-100 dark:hover:bg-gray-700 lg:hidden"
className="p-2 rounded-xl hover:bg-gray-100 dark:hover:bg-gray-700 transition-colors"
>
<X className="h-5 w-5" />
<X className="h-5 w-5 text-gray-600 dark:text-gray-400" />
</button>
</div>
<div className="p-4">
<div className="flex items-center mb-6 p-3 bg-gray-50 dark:bg-gray-700 rounded-lg">
<div className="w-10 h-10 bg-primary-600 rounded-full flex items-center justify-center">
<span className="text-white font-medium">
{user?.name?.charAt(0) || 'A'}
</span>
</div>
<div className="mr-3">
<p className="text-sm font-medium text-gray-900 dark:text-gray-100">
{user?.name || 'کاربر'}
</p>
<p className="text-xs text-gray-500 dark:text-gray-400">
{user?.role || 'مدیر'}
</p>
</div>
{/* Logo - desktop only */}
<div className="hidden lg:flex h-16 items-center justify-center border-b border-gray-200 dark:border-gray-700 flex-shrink-0">
<SectionTitle>
پنل مدیریت
</SectionTitle>
</div>
<nav className="space-y-2">
{menuItems.map(item => (
<div key={item.id}>
{item.permission ? (
<PermissionWrapper permission={item.permission}>
{renderMenuItem(item)}
</PermissionWrapper>
) : (
renderMenuItem(item)
)}
</div>
))}
{/* Navigation */}
<nav className="flex-1 space-y-1 px-4 py-6 overflow-y-auto min-h-0">
{menuItems.map(item => renderMenuItem(item))}
</nav>
{/* User Info */}
<div className="border-t border-gray-200 dark:border-gray-700 p-4 flex-shrink-0">
<div className="flex items-center space-x-3 space-x-reverse">
<div className="h-8 w-8 rounded-full bg-primary-600 flex items-center justify-center">
<span className="text-sm font-medium text-white">
{user?.first_name?.[0]}{user?.last_name?.[0]}
</span>
</div>
<div className="flex-1 min-w-0">
<SmallText>
{user?.first_name} {user?.last_name}
</SmallText>
<SmallText>
{user?.username}
</SmallText>
</div>
<button
onClick={logout}
className="text-gray-500 hover:text-red-600 dark:text-gray-400 dark:hover:text-red-400"
>
<LogOut className="h-5 w-5" />
</button>
</div>
</div>
</div>
</>

View File

@ -1,12 +1,13 @@
import { clsx } from 'clsx';
import { MouseEvent, ButtonHTMLAttributes } from 'react';
interface ButtonProps {
interface ButtonProps extends Omit<ButtonHTMLAttributes<HTMLButtonElement>, 'type' | 'onClick'> {
children: any;
variant?: 'primary' | 'secondary' | 'danger' | 'success';
size?: 'sm' | 'md' | 'lg';
disabled?: boolean;
loading?: boolean;
onClick?: () => void;
onClick?: (e?: MouseEvent<HTMLButtonElement>) => void;
type?: 'button' | 'submit' | 'reset';
className?: string;
}
@ -20,8 +21,9 @@ export const Button = ({
onClick,
type = 'button',
className = '',
...rest
}: ButtonProps) => {
const baseClasses = 'inline-flex items-center justify-center rounded-lg font-medium transition-all duration-200 focus:outline-none focus:ring-2 focus:ring-offset-2';
const baseClasses = 'inline-flex items-center justify-center rounded-xl font-medium transition-all duration-200 focus:outline-none focus:ring-2 focus:ring-offset-2 shadow-sm hover:shadow-md';
const variantClasses = {
primary: 'bg-primary-600 hover:bg-primary-700 text-white focus:ring-primary-500',
@ -52,6 +54,7 @@ export const Button = ({
disabledClasses,
className
)}
{...rest}
>
{loading && (
<svg

View File

@ -0,0 +1,351 @@
import React, { useState, useRef, useCallback, useEffect } from 'react';
import { Upload, X, Image, File, AlertCircle, CheckCircle } from 'lucide-react';
import { Button } from './Button';
export interface UploadedFile {
id: string;
name: string;
size: number;
type: string;
url?: string;
preview?: string;
progress: number;
status: 'uploading' | 'completed' | 'error';
error?: string;
}
interface FileUploaderProps {
onUpload: (file: File) => Promise<{ id: string; url: string; mimeType?: string }>;
onRemove?: (fileId: string) => void;
acceptedTypes?: string[];
maxFileSize?: number;
maxFiles?: number;
label?: string;
description?: string;
error?: string;
disabled?: boolean;
className?: string;
mode?: 'single' | 'multi';
onUploadStart?: () => void;
onUploadComplete?: () => void;
initialFiles?: Array<Partial<UploadedFile> & { id: string; url?: string }>;
}
export const FileUploader: React.FC<FileUploaderProps> = ({
onUpload,
onRemove,
acceptedTypes = ['image/*', 'video/*'],
maxFileSize = 10 * 1024 * 1024,
maxFiles = 10,
label = "فایل‌ها",
description = "تصاویر و ویدیوها را اینجا بکشید یا کلیک کنید",
error,
disabled = false,
className = "",
mode = 'multi',
onUploadStart,
onUploadComplete,
initialFiles = [],
}) => {
const [files, setFiles] = useState<UploadedFile[]>([]);
const [isDragOver, setIsDragOver] = useState(false);
const fileInputRef = useRef<HTMLInputElement>(null);
const isImage = (type: string) => type.startsWith('image/');
const formatFileSize = (bytes: number) => {
if (bytes === 0) return '0 Bytes';
const k = 1024;
const sizes = ['Bytes', 'KB', 'MB', 'GB'];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i];
};
useEffect(() => {
if (initialFiles && initialFiles.length > 0) {
const normalized: UploadedFile[] = initialFiles.map((f) => ({
id: f.id,
name: f.name || (f.url ? f.url.split('/').pop() || 'file' : 'file'),
size: typeof f.size === 'number' ? f.size : 0,
type: f.type || 'image/*',
url: f.url,
preview: f.preview,
progress: 100,
status: 'completed',
}));
setFiles(mode === 'single' ? [normalized[0]] : normalized);
}
}, [initialFiles, mode]);
const validateFile = (file: File) => {
if (maxFileSize && file.size > maxFileSize) {
return `حجم فایل نباید بیشتر از ${formatFileSize(maxFileSize)} باشد`;
}
if (acceptedTypes.length > 0) {
const isAccepted = acceptedTypes.some(type => {
if (type === 'image/*') return file.type.startsWith('image/');
if (type === 'video/*') return file.type.startsWith('video/');
return file.type === type;
});
if (!isAccepted) {
return 'نوع فایل پشتیبانی نمی‌شود';
}
}
if (maxFiles && files.length >= maxFiles) {
return `حداکثر ${maxFiles} فایل مجاز است`;
}
return null;
};
const createFilePreview = (file: File) => {
return new Promise<string>((resolve) => {
if (isImage(file.type)) {
const reader = new FileReader();
reader.onload = (e) => resolve(e.target?.result as string);
reader.readAsDataURL(file);
} else {
resolve('');
}
});
};
const handleFileUpload = useCallback(async (file: File) => {
const validationError = validateFile(file);
if (validationError) {
const errorFile: UploadedFile = {
id: Math.random().toString(36).substr(2, 9),
name: file.name,
size: file.size,
type: file.type,
progress: 0,
status: 'error',
error: validationError,
};
setFiles(prev => mode === 'single' ? [errorFile] : [...prev, errorFile]);
return;
}
onUploadStart?.();
const fileId = Math.random().toString(36).substr(2, 9);
const preview = await createFilePreview(file);
const newFile: UploadedFile = {
id: fileId,
name: file.name,
size: file.size,
type: file.type,
preview,
progress: 0,
status: 'uploading',
};
setFiles(prev => mode === 'single' ? [newFile] : [...prev, newFile]);
try {
const progressInterval = setInterval(() => {
setFiles(prev => prev.map(f =>
f.id === fileId && f.progress < 90
? { ...f, progress: f.progress + 10 }
: f
));
}, 200);
const result = await onUpload(file);
clearInterval(progressInterval);
setFiles(prev => prev.map(f =>
f.id === fileId
? { ...f, progress: 100, status: 'completed', url: result.url, id: result.id }
: f
));
onUploadComplete?.();
} catch (error: any) {
setFiles(prev => prev.map(f =>
f.id === fileId
? { ...f, status: 'error', error: error.message || 'خطا در آپلود فایل' }
: f
));
onUploadComplete?.();
}
}, [onUpload, maxFiles, maxFileSize, acceptedTypes, mode, onUploadStart, onUploadComplete]);
const handleFileSelect = useCallback((selectedFiles: FileList) => {
Array.from(selectedFiles).forEach(file => {
handleFileUpload(file);
});
}, [handleFileUpload]);
const handleDrop = useCallback((e: React.DragEvent) => {
e.preventDefault();
setIsDragOver(false);
if (disabled) return;
const droppedFiles = e.dataTransfer.files;
handleFileSelect(droppedFiles);
}, [disabled, handleFileSelect]);
const handleDragOver = useCallback((e: React.DragEvent) => {
e.preventDefault();
if (!disabled) setIsDragOver(true);
}, [disabled]);
const handleDragLeave = useCallback((e: React.DragEvent) => {
e.preventDefault();
setIsDragOver(false);
}, []);
const handleClick = () => {
if (!disabled) fileInputRef.current?.click();
};
const handleRemove = (fileId: string) => {
setFiles(prev => prev.filter(f => f.id !== fileId));
onRemove?.(fileId);
};
const hasUploadedFiles = files.some(f => f.status === 'completed');
const showUploadArea = mode === 'multi' || (mode === 'single' && !hasUploadedFiles);
return (
<div className={`space-y-4 ${className}`}>
{label && (
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300">
{label}
</label>
)}
{showUploadArea && (
<div
className={`
relative border-2 border-dashed rounded-lg p-6 transition-colors cursor-pointer
${isDragOver ? 'border-primary-400 bg-primary-50 dark:bg-primary-900/20' : 'border-gray-300 dark:border-gray-600'}
${disabled ? 'opacity-50 cursor-not-allowed' : 'hover:border-primary-400 hover:bg-gray-50 dark:hover:bg-gray-700'}
${error ? 'border-red-300 bg-red-50 dark:bg-red-900/20' : ''}
`}
onDrop={handleDrop}
onDragOver={handleDragOver}
onDragLeave={handleDragLeave}
onClick={handleClick}
>
<input
ref={fileInputRef}
type="file"
multiple={mode === 'multi'}
accept={acceptedTypes.join(',')}
className="hidden"
onChange={(e) => e.target.files && handleFileSelect(e.target.files)}
disabled={disabled}
/>
<div className="text-center">
<Upload className="mx-auto h-12 w-12 text-gray-400" />
<div className="mt-4">
<p className="text-sm text-gray-600 dark:text-gray-400">
{description}
</p>
<p className="text-xs text-gray-500 dark:text-gray-500 mt-1">
حداکثر {formatFileSize(maxFileSize)} {acceptedTypes.join(', ')}
</p>
</div>
</div>
</div>
)}
{error && (
<p className="text-sm text-red-600 dark:text-red-400 flex items-center gap-1">
<AlertCircle className="h-4 w-4" />
{error}
</p>
)}
{files.length > 0 && (
<div className="space-y-3">
<h4 className="text-sm font-medium text-gray-700 dark:text-gray-300">
فایلهای آپلود شده ({files.length})
</h4>
<div className="space-y-2">
{files.map((file) => (
<div
key={file.id}
className="flex items-center gap-3 p-3 bg-gray-50 dark:bg-gray-700 rounded-lg"
>
<div className="flex-shrink-0">
{(file.preview || file.url) ? (
<img
src={(file.preview || file.url) as string}
alt={file.name}
className="w-10 h-10 object-cover rounded"
/>
) : (
<div className="w-10 h-10 bg-gray-200 dark:bg-gray-600 rounded flex items-center justify-center">
{isImage(file.type) ? (
<Image className="h-5 w-5 text-gray-500" />
) : (
<File className="h-5 w-5 text-gray-500" />
)}
</div>
)}
</div>
<div className="flex-1 min-w-0">
<p className="text-sm font-medium text-gray-900 dark:text-gray-100 truncate">
{file.name}
</p>
<p className="text-xs text-gray-500 dark:text-gray-400">
{formatFileSize(file.size)}
</p>
{file.status === 'uploading' && (
<div className="mt-1">
<div className="w-full bg-gray-200 dark:bg-gray-600 rounded-full h-1.5">
<div
className="bg-primary-600 h-1.5 rounded-full transition-all duration-300"
style={{ width: `${file.progress}%` }}
/>
</div>
<p className="text-xs text-gray-500 mt-1">{file.progress}%</p>
</div>
)}
{file.status === 'error' && file.error && (
<p className="text-xs text-red-600 dark:text-red-400 mt-1 flex items-center gap-1">
<AlertCircle className="h-3 w-3" />
{file.error}
</p>
)}
</div>
<div className="flex items-center gap-2">
{file.status === 'completed' && (
<CheckCircle className="h-5 w-5 text-green-500" />
)}
{file.status === 'error' && (
<AlertCircle className="h-5 w-5 text-red-500" />
)}
<Button
variant="secondary"
size="sm"
onClick={(e) => {
e?.stopPropagation();
handleRemove(file.id);
}}
className="p-1 h-8 w-8"
>
<X className="h-4 w-4" />
</Button>
</div>
</div>
))}
</div>
</div>
)}
</div>
);
};

View File

@ -1,54 +1,88 @@
import { forwardRef } from 'react';
import React from 'react';
import { clsx } from 'clsx';
import { Label } from './Typography';
import { persianToEnglish, formatWithThousands } from '../../utils/numberUtils';
interface InputProps {
interface InputProps extends Omit<React.InputHTMLAttributes<HTMLInputElement>, 'size'> {
label?: string;
error?: string;
type?: string;
placeholder?: string;
className?: string;
icon?: any;
disabled?: boolean;
helperText?: string;
inputSize?: 'sm' | 'md' | 'lg';
icon?: React.ComponentType<{ className?: string }>;
numeric?: boolean;
thousandSeparator?: boolean;
}
export const Input = forwardRef<HTMLInputElement, InputProps>(
({ label, error, type = 'text', placeholder, className, icon: Icon, disabled, ...props }, ref) => {
export const Input = React.forwardRef<HTMLInputElement, InputProps>(
({ 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',
lg: 'px-4 py-4 text-lg'
};
const inputClasses = clsx(
'w-full border rounded-lg transition-all duration-200 focus:outline-none focus:ring-2',
sizeClasses[inputSize],
error
? 'border-red-300 focus:border-red-500 focus:ring-red-500'
: 'border-gray-300 dark:border-gray-600 focus:border-primary-500 focus:ring-primary-500',
'bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100',
'placeholder-gray-500 dark:placeholder-gray-400',
className
);
const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
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);
};
const getInputMode = (): "numeric" | "decimal" | undefined => {
if (numeric) {
return type === 'number' ? 'decimal' : 'numeric';
}
return undefined;
};
const inputProps = {
ref,
id,
type: numeric || thousandSeparator ? 'text' : type,
inputMode: getInputMode(),
className: inputClasses,
onChange: handleChange,
...props
};
return (
<div className="space-y-1">
{label && (
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300">
{label}
</label>
{label && <Label htmlFor={id}>{label}</Label>}
<input {...inputProps} />
{helperText && !error && (
<p className="text-xs text-gray-500 dark:text-gray-400">{helperText}</p>
)}
<div className="relative">
{Icon && (
<div className="absolute inset-y-0 right-0 pr-3 flex items-center pointer-events-none">
<Icon className="h-5 w-5 text-gray-400" />
</div>
)}
<input
ref={ref}
type={type}
placeholder={placeholder}
disabled={disabled}
className={clsx(
'input',
Icon && 'pr-10',
error && 'border-red-500 dark:border-red-500 focus:ring-red-500',
disabled && 'opacity-50 cursor-not-allowed',
className
)}
{...props}
/>
</div>
{error && (
<p className="text-sm text-red-600 dark:text-red-400">
{error}
</p>
<p className="text-xs text-red-600 dark:text-red-400" role="alert">{error}</p>
)}
</div>
);
}
);
Input.displayName = 'Input';

View File

@ -0,0 +1,91 @@
import React from 'react';
import DatePicker from 'react-multi-date-picker';
import TimePicker from 'react-multi-date-picker/plugins/time_picker';
import persian from 'react-date-object/calendars/persian';
import persian_fa from 'react-date-object/locales/persian_fa';
import DateObject from 'react-date-object';
import { Label } from './Typography';
import { X } from 'lucide-react';
interface JalaliDateTimePickerProps {
label?: string;
value?: string | null;
onChange: (value: string | undefined) => void;
error?: string;
placeholder?: string;
}
const toIsoLike = (date?: DateObject | null): string | undefined => {
if (!date) return undefined;
try {
const g = date.convert(undefined);
const yyyy = g.year.toString().padStart(4, '0');
const mm = g.month.toString().padStart(2, '0');
const dd = g.day.toString().padStart(2, '0');
const hh = g.hour.toString().padStart(2, '0');
const mi = g.minute.toString().padStart(2, '0');
return `${yyyy}-${mm}-${dd}T${hh}:${mi}:00Z`;
} catch {
return undefined;
}
};
const fromIsoToDateObject = (value?: string | null): DateObject | undefined => {
if (!value) return undefined;
try {
const d = new Date(value);
if (isNaN(d.getTime())) return undefined;
return new DateObject(d).convert(persian, persian_fa);
} catch {
return undefined;
}
};
export const JalaliDateTimePicker: React.FC<JalaliDateTimePickerProps> = ({ label, value, onChange, error, placeholder }) => {
const selected = fromIsoToDateObject(value);
return (
<div className="space-y-1">
{label && <Label>{label}</Label>}
<div className="relative">
<DatePicker
value={selected}
onChange={(val) => onChange(toIsoLike(val as DateObject | null))}
format="YYYY/MM/DD HH:mm"
calendar={persian}
locale={persian_fa}
calendarPosition="bottom-center"
disableDayPicker={false}
inputClass={`w-full border rounded-lg px-3 py-3 pr-10 bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 placeholder-gray-500 dark:placeholder-gray-400 focus:outline-none focus:ring-2 ${error ? 'border-red-300 focus:border-red-500 focus:ring-red-500' : 'border-gray-300 dark:border-gray-600 focus:border-primary-500 focus:ring-primary-500'}`}
containerClassName="w-full"
placeholder={placeholder || 'تاریخ و ساعت'}
editable={false}
plugins={[<TimePicker key="time" position="bottom" />]}
disableMonthPicker={false}
disableYearPicker={false}
showOtherDays
/>
{value && (
<button
type="button"
onClick={(e) => {
e.stopPropagation();
onChange(undefined);
}}
className="absolute left-2 top-1/2 -translate-y-1/2 p-1 text-gray-400 hover:text-gray-600 dark:hover:text-gray-300 rounded hover:bg-gray-100 dark:hover:bg-gray-600 transition-colors"
title="پاک کردن"
>
<X className="h-4 w-4" />
</button>
)}
</div>
{error && (
<p className="text-xs text-red-600 dark:text-red-400" role="alert">{error}</p>
)}
</div>
);
};
export default JalaliDateTimePicker;

View File

@ -1,12 +1,15 @@
import { useEffect } from 'react';
import React, { useEffect } from 'react';
import { X } from 'lucide-react';
import { SectionSubtitle } from './Typography';
interface ModalProps {
isOpen: boolean;
onClose: () => void;
title?: string;
children: any;
title: string;
children: React.ReactNode;
size?: 'sm' | 'md' | 'lg' | 'xl';
showCloseButton?: boolean;
actions?: React.ReactNode;
}
export const Modal = ({
@ -14,7 +17,9 @@ export const Modal = ({
onClose,
title,
children,
size = 'md'
size = 'md',
showCloseButton = true,
actions
}: ModalProps) => {
useEffect(() => {
const handleEscape = (e: KeyboardEvent) => {
@ -40,7 +45,7 @@ export const Modal = ({
sm: 'max-w-md',
md: 'max-w-lg',
lg: 'max-w-2xl',
xl: 'max-w-4xl',
xl: 'max-w-4xl'
};
return (
@ -52,26 +57,31 @@ export const Modal = ({
/>
<div className={`
relative bg-white dark:bg-gray-800 rounded-lg shadow-xl w-full
${sizeClasses[size]} transform transition-all
relative w-full ${sizeClasses[size]}
bg-white dark:bg-gray-800 rounded-2xl shadow-2xl
transform transition-all border border-gray-200 dark:border-gray-700
`}>
{title && (
<div className="flex items-center justify-between p-6 border-b border-gray-200 dark:border-gray-700">
<h3 className="text-lg font-semibold text-gray-900 dark:text-gray-100">
{title}
</h3>
<div className="flex items-center justify-between p-4 sm:p-6 border-b border-gray-200 dark:border-gray-700">
<SectionSubtitle>{title}</SectionSubtitle>
{showCloseButton && (
<button
onClick={onClose}
className="p-1 rounded-lg hover:bg-gray-100 dark:hover:bg-gray-700 transition-colors"
className="text-gray-400 hover:text-gray-600 dark:hover:text-gray-300 transition-colors"
>
<X className="h-5 w-5 text-gray-500 dark:text-gray-400" />
<X className="h-5 w-5" />
</button>
</div>
)}
</div>
<div className="p-6">
<div className="p-4 sm:p-6">
{children}
</div>
{actions && (
<div className="flex flex-col space-y-2 sm:flex-row sm:justify-end sm:space-y-0 sm:space-x-3 sm:space-x-reverse p-4 sm:p-6 border-t border-gray-200 dark:border-gray-700">
{actions}
</div>
)}
</div>
</div>
</div>

View File

@ -0,0 +1,219 @@
import React, { useState, useRef, useEffect } from 'react';
import { ChevronDown, X } from 'lucide-react';
export interface Option {
id: number;
title: string;
description?: string;
}
interface MultiSelectAutocompleteProps {
options: Option[];
selectedValues: number[];
onChange: (values: number[]) => void;
placeholder?: string;
label?: string;
error?: string;
isLoading?: boolean;
disabled?: boolean;
onSearchChange?: (query: string) => void;
onLoadMore?: () => void;
hasMore?: boolean;
loadingMore?: boolean;
}
export const MultiSelectAutocomplete: React.FC<MultiSelectAutocompleteProps> = ({
options,
selectedValues,
onChange,
placeholder = "انتخاب کنید...",
label,
error,
isLoading = false,
disabled = false,
onSearchChange,
onLoadMore,
hasMore = false,
loadingMore = false,
}) => {
const [isOpen, setIsOpen] = useState(false);
const [searchTerm, setSearchTerm] = useState('');
const dropdownRef = useRef<HTMLDivElement>(null);
const inputRef = useRef<HTMLInputElement>(null);
const listRef = useRef<HTMLDivElement>(null);
const filteredOptions = options.filter(option =>
option.title.toLowerCase().includes(searchTerm.toLowerCase()) ||
(option.description && option.description.toLowerCase().includes(searchTerm.toLowerCase()))
);
// If parent provides onSearchChange, assume server-side filtering and use options as-is
const displayedOptions = onSearchChange ? options : filteredOptions;
const selectedOptions = options.filter(option => selectedValues.includes(option.id));
useEffect(() => {
const handleClickOutside = (event: MouseEvent) => {
if (dropdownRef.current && !dropdownRef.current.contains(event.target as Node)) {
setIsOpen(false);
setSearchTerm('');
}
};
document.addEventListener('mousedown', handleClickOutside);
return () => document.removeEventListener('mousedown', handleClickOutside);
}, []);
const handleToggleOption = (optionId: number) => {
if (selectedValues.includes(optionId)) {
onChange(selectedValues.filter(id => id !== optionId));
} else {
onChange([...selectedValues, optionId]);
}
};
const handleRemoveOption = (optionId: number) => {
onChange(selectedValues.filter(id => id !== optionId));
};
const handleToggleDropdown = () => {
if (disabled) return;
setIsOpen(!isOpen);
if (!isOpen) {
setTimeout(() => inputRef.current?.focus(), 100);
}
};
return (
<div className="relative" ref={dropdownRef}>
{label && (
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
{label}
</label>
)}
{/* Selected Items Display */}
<div
className={`
w-full px-3 py-3 text-base border rounded-lg
focus-within:outline-none focus-within:ring-2 focus-within:ring-primary-500
cursor-pointer transition-all duration-200
${error ? 'border-red-300 focus-within:border-red-500 focus-within:ring-red-500' : 'border-gray-300 dark:border-gray-600 focus-within:border-primary-500'}
${disabled ? 'bg-gray-100 cursor-not-allowed' : 'bg-white dark:bg-gray-700'}
dark:text-gray-100 placeholder-gray-500 dark:placeholder-gray-400
`}
onClick={handleToggleDropdown}
>
<div className="flex flex-wrap gap-1 items-center">
{selectedOptions.length > 0 ? (
selectedOptions.map(option => (
<span
key={option.id}
className="inline-flex items-center gap-1 px-2 py-1 bg-primary-100 dark:bg-primary-800 text-primary-800 dark:text-primary-100 text-xs rounded-md"
>
{option.title || option.description || `#${option.id}`}
<button
type="button"
onClick={(e) => {
e.stopPropagation();
handleRemoveOption(option.id);
}}
className="hover:bg-primary-200 dark:hover:bg-primary-700 rounded-full p-0.5 transition-colors"
disabled={disabled}
>
<X className="h-3 w-3" />
</button>
</span>
))
) : (
<span className="text-gray-500 dark:text-gray-400">{placeholder}</span>
)}
<div className="flex-1 min-w-[60px]">
{isOpen && !disabled && (
<input
ref={inputRef}
type="text"
value={searchTerm}
onChange={(e) => {
const value = e.target.value;
setSearchTerm(value);
if (onSearchChange) onSearchChange(value);
}}
className="w-full border-none outline-none bg-transparent text-sm"
placeholder="جستجو..."
/>
)}
</div>
<ChevronDown
className={`h-4 w-4 transition-transform ${isOpen ? 'rotate-180' : ''}`}
/>
</div>
</div>
{/* Dropdown */}
{isOpen && !disabled && (
<div
ref={listRef}
onScroll={() => {
const el = listRef.current;
if (!el || !onLoadMore || !hasMore || loadingMore) return;
const nearBottom = el.scrollTop + el.clientHeight >= el.scrollHeight - 24;
if (nearBottom) onLoadMore();
}}
className="absolute z-50 w-full mt-1 bg-white dark:bg-gray-800 border border-gray-300 dark:border-gray-600 rounded-md shadow-lg max-h-60 overflow-auto"
>
{isLoading ? (
<div className="p-3 text-center text-gray-500 dark:text-gray-400">
در حال بارگذاری...
</div>
) : displayedOptions.length > 0 ? (
<>
{displayedOptions.map(option => (
<div
key={option.id}
className={`
px-3 py-2 cursor-pointer hover:bg-gray-100 dark:hover:bg-gray-700
${selectedValues.includes(option.id) ? 'bg-primary-200 dark:bg-primary-700/70' : ''}
`}
onClick={() => handleToggleOption(option.id)}
>
<div className="flex items-center justify-between">
<div>
<div className="text-sm font-medium text-gray-900 dark:text-gray-100">
{option.title}
</div>
{option.description && (
<div className="text-xs text-gray-500 dark:text-gray-400 mt-1">
{option.description}
</div>
)}
</div>
{selectedValues.includes(option.id) && (
<div className="text-primary-600 dark:text-primary-400"></div>
)}
</div>
</div>
))}
{onLoadMore && hasMore && (
<div className="p-2 text-center text-xs text-gray-500 dark:text-gray-400">
{loadingMore ? 'در حال بارگذاری بیشتر...' : 'اسکرول برای مشاهده بیشتر'}
</div>
)}
</>
) : (
<div className="p-3 text-center text-gray-500 dark:text-gray-400">
موردی یافت نشد
</div>
)}
</div>
)}
{error && (
<p className="mt-1 text-sm text-red-600 dark:text-red-400">{error}</p>
)}
</div>
);
};

View File

@ -0,0 +1,207 @@
import React, { useState, useRef, useEffect } from 'react';
import { ChevronDown, X } from 'lucide-react';
export interface Option {
id: number;
title: string;
description?: string;
}
interface SingleSelectAutocompleteProps {
options: Option[];
selectedValue?: number;
onChange: (value?: number) => void;
placeholder?: string;
label?: string;
error?: string;
isLoading?: boolean;
disabled?: boolean;
onSearchChange?: (query: string) => void;
onLoadMore?: () => void;
hasMore?: boolean;
loadingMore?: boolean;
}
export const SingleSelectAutocomplete: React.FC<SingleSelectAutocompleteProps> = ({
options,
selectedValue,
onChange,
placeholder = "انتخاب کنید...",
label,
error,
isLoading = false,
disabled = false,
onSearchChange,
onLoadMore,
hasMore = false,
loadingMore = false,
}) => {
const [isOpen, setIsOpen] = useState(false);
const [searchTerm, setSearchTerm] = useState('');
const dropdownRef = useRef<HTMLDivElement>(null);
const inputRef = useRef<HTMLInputElement>(null);
const listRef = useRef<HTMLDivElement>(null);
const filteredOptions = options.filter(option =>
option.title.toLowerCase().includes(searchTerm.toLowerCase()) ||
(option.description && option.description.toLowerCase().includes(searchTerm.toLowerCase()))
);
const displayedOptions = onSearchChange ? options : filteredOptions;
const selectedOption = options.find(option => option.id === selectedValue);
useEffect(() => {
const handleClickOutside = (event: MouseEvent) => {
if (dropdownRef.current && !dropdownRef.current.contains(event.target as Node)) {
setIsOpen(false);
setSearchTerm('');
}
};
document.addEventListener('mousedown', handleClickOutside);
return () => document.removeEventListener('mousedown', handleClickOutside);
}, []);
const handleSelectOption = (optionId: number) => {
onChange(optionId);
setIsOpen(false);
setSearchTerm('');
};
const handleClearSelection = (e: React.MouseEvent) => {
e.stopPropagation();
onChange(undefined);
};
const handleToggleDropdown = () => {
if (disabled) return;
setIsOpen(!isOpen);
if (!isOpen) {
setTimeout(() => inputRef.current?.focus(), 100);
}
};
const handleSearchChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const value = e.target.value;
setSearchTerm(value);
if (onSearchChange) {
onSearchChange(value);
}
};
const handleScroll = (e: React.UIEvent<HTMLDivElement>) => {
const { scrollTop, scrollHeight, clientHeight } = e.currentTarget;
if (scrollHeight - scrollTop <= clientHeight + 5 && hasMore && !loadingMore && onLoadMore) {
onLoadMore();
}
};
return (
<div className="relative" ref={dropdownRef}>
{label && (
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
{label}
</label>
)}
<div
className={`
w-full px-3 py-3 text-base border rounded-lg
focus-within:outline-none focus-within:ring-2 focus-within:ring-primary-500
cursor-pointer transition-all duration-200
${error ? 'border-red-300 focus-within:border-red-500 focus-within:ring-red-500' : 'border-gray-300 dark:border-gray-600 focus-within:border-primary-500'}
${disabled ? 'bg-gray-100 cursor-not-allowed' : 'bg-white dark:bg-gray-700'}
dark:text-gray-100 placeholder-gray-500 dark:placeholder-gray-400
`}
onClick={handleToggleDropdown}
>
<div className="flex items-center justify-between">
<div className="flex-1">
{selectedOption ? (
<div className="flex items-center justify-between">
<div>
<span className="text-sm font-medium">{selectedOption.title}</span>
{selectedOption.description && (
<span className="text-xs text-gray-500 dark:text-gray-400 block">
{selectedOption.description}
</span>
)}
</div>
<button
type="button"
onClick={handleClearSelection}
className="ml-2 p-1 hover:bg-gray-200 dark:hover:bg-gray-600 rounded"
>
<X className="h-3 w-3" />
</button>
</div>
) : (
<span className="text-gray-500 dark:text-gray-400">{placeholder}</span>
)}
</div>
<ChevronDown className={`h-4 w-4 transition-transform ${isOpen ? 'rotate-180' : ''}`} />
</div>
</div>
{isOpen && (
<div className="absolute z-50 w-full mt-1 bg-white dark:bg-gray-700 border border-gray-300 dark:border-gray-600 rounded-md shadow-lg">
<div className="p-2 border-b border-gray-200 dark:border-gray-600">
<input
ref={inputRef}
type="text"
value={searchTerm}
onChange={handleSearchChange}
placeholder="جستجو..."
className="w-full px-3 py-2 text-sm border border-gray-300 dark:border-gray-600 rounded focus:outline-none focus:ring-1 focus:ring-primary-500 dark:bg-gray-600 dark:text-gray-100"
/>
</div>
<div
ref={listRef}
className="max-h-60 overflow-y-auto"
onScroll={handleScroll}
>
{isLoading && displayedOptions.length === 0 ? (
<div className="p-3 text-center text-gray-500 dark:text-gray-400">
در حال بارگذاری...
</div>
) : displayedOptions.length === 0 ? (
<div className="p-3 text-center text-gray-500 dark:text-gray-400">
موردی یافت نشد
</div>
) : (
<>
{displayedOptions.map((option) => (
<div
key={option.id}
className={`
p-3 cursor-pointer hover:bg-gray-50 dark:hover:bg-gray-600
${selectedValue === option.id ? 'bg-blue-50 dark:bg-blue-900' : ''}
`}
onClick={() => handleSelectOption(option.id)}
>
<div className="font-medium text-sm">{option.title}</div>
{option.description && (
<div className="text-xs text-gray-500 dark:text-gray-400 mt-1">
{option.description}
</div>
)}
</div>
))}
{loadingMore && (
<div className="p-3 text-center text-gray-500 dark:text-gray-400">
در حال بارگذاری بیشتر...
</div>
)}
</>
)}
</div>
</div>
)}
{error && (
<p className="mt-1 text-sm text-red-600 dark:text-red-400">{error}</p>
)}
</div>
);
};

View File

@ -0,0 +1,215 @@
import React from 'react';
export type StatusType = 'product' | 'order' | 'user' | 'discount' | 'comment' | 'generic';
export type ProductStatus = 'active' | 'inactive' | 'draft';
export type OrderStatus = 'pending' | 'processing' | 'shipped' | 'delivered' | 'cancelled' | 'refunded';
export type UserStatus = 'verified' | 'unverified' | boolean;
export type DiscountStatus = 'active' | 'inactive';
export type CommentStatus = 'approved' | 'rejected' | 'pending';
export type StatusValue = ProductStatus | OrderStatus | UserStatus | DiscountStatus | CommentStatus | string;
interface StatusBadgeProps {
status: StatusValue;
type?: StatusType;
className?: string;
size?: 'sm' | 'md' | 'lg';
}
const getStatusConfig = (status: StatusValue, type?: StatusType) => {
// Handle boolean status (for verified/unverified)
if (typeof status === 'boolean') {
return {
color: status
? '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',
text: status ? 'تأیید شده' : 'تأیید نشده',
};
}
const statusStr = String(status).toLowerCase();
switch (type) {
case 'product':
switch (statusStr) {
case 'active':
return {
color: 'bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200',
text: 'فعال',
};
case 'inactive':
return {
color: 'bg-red-100 text-red-800 dark:bg-red-900 dark:text-red-200',
text: 'غیرفعال',
};
case 'draft':
return {
color: 'bg-gray-100 text-gray-800 dark:bg-gray-900 dark:text-gray-200',
text: 'پیش‌نویس',
};
default:
return {
color: 'bg-gray-100 text-gray-800 dark:bg-gray-900 dark:text-gray-200',
text: statusStr,
};
}
case 'order':
switch (statusStr) {
case 'pending':
return {
color: 'bg-yellow-100 text-yellow-800 dark:bg-yellow-900 dark:text-yellow-200',
text: 'در انتظار',
};
case 'processing':
return {
color: 'bg-blue-100 text-blue-800 dark:bg-blue-900 dark:text-blue-200',
text: 'در حال پردازش',
};
case 'shipped':
return {
color: 'bg-purple-100 text-purple-800 dark:bg-purple-900 dark:text-purple-200',
text: 'ارسال شده',
};
case 'delivered':
return {
color: 'bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200',
text: 'تحویل شده',
};
case 'cancelled':
return {
color: 'bg-red-100 text-red-800 dark:bg-red-900 dark:text-red-200',
text: 'لغو شده',
};
case 'refunded':
return {
color: 'bg-gray-100 text-gray-800 dark:bg-gray-900 dark:text-gray-200',
text: 'مرجوع شده',
};
default:
return {
color: 'bg-gray-100 text-gray-800 dark:bg-gray-900 dark:text-gray-200',
text: statusStr,
};
}
case 'user':
switch (statusStr) {
case 'verified':
case 'true':
return {
color: 'bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200',
text: 'تأیید شده',
};
case 'unverified':
case 'false':
return {
color: 'bg-yellow-100 text-yellow-800 dark:bg-yellow-900 dark:text-yellow-200',
text: 'تأیید نشده',
};
default:
return {
color: 'bg-gray-100 text-gray-800 dark:bg-gray-900 dark:text-gray-200',
text: statusStr,
};
}
case 'discount':
switch (statusStr) {
case 'active':
return {
color: 'bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200',
text: 'فعال',
};
case 'inactive':
return {
color: 'bg-red-100 text-red-800 dark:bg-red-900 dark:text-red-200',
text: 'غیرفعال',
};
default:
return {
color: 'bg-gray-100 text-gray-800 dark:bg-gray-900 dark:text-gray-200',
text: statusStr,
};
}
case 'comment':
switch (statusStr) {
case 'approved':
return {
color: 'bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200',
text: 'تأیید شده',
};
case 'rejected':
return {
color: 'bg-red-100 text-red-800 dark:bg-red-900 dark:text-red-200',
text: 'رد شده',
};
case 'pending':
return {
color: 'bg-yellow-100 text-yellow-800 dark:bg-yellow-900 dark:text-yellow-200',
text: 'در انتظار',
};
default:
return {
color: 'bg-gray-100 text-gray-800 dark:bg-gray-900 dark:text-gray-200',
text: statusStr,
};
}
default:
// Generic status handling
switch (statusStr) {
case 'active':
case 'true':
return {
color: 'bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200',
text: 'فعال',
};
case 'inactive':
case 'false':
return {
color: 'bg-red-100 text-red-800 dark:bg-red-900 dark:text-red-200',
text: 'غیرفعال',
};
default:
return {
color: 'bg-gray-100 text-gray-800 dark:bg-gray-900 dark:text-gray-200',
text: statusStr,
};
}
}
};
const getSizeClasses = (size: 'sm' | 'md' | 'lg') => {
switch (size) {
case 'sm':
return 'px-2 py-0.5 text-xs';
case 'md':
return 'px-2.5 py-0.5 text-xs';
case 'lg':
return 'px-3 py-1 text-sm';
default:
return 'px-2.5 py-0.5 text-xs';
}
};
export const StatusBadge: React.FC<StatusBadgeProps> = ({
status,
type = 'generic',
className = '',
size = 'md',
}) => {
const config = getStatusConfig(status, type);
const sizeClasses = getSizeClasses(size);
return (
<span
className={`inline-flex items-center rounded-full font-medium ${config.color} ${sizeClasses} ${className}`}
>
{config.text}
</span>
);
};

View File

@ -75,7 +75,7 @@ export const Table = ({ columns, data, loading = false }: TableProps) => {
return (
<>
<div className="hidden md:block card overflow-hidden">
<div className="hidden md:block card 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>
@ -83,15 +83,22 @@ export const Table = ({ columns, data, loading = false }: TableProps) => {
<th
key={column.key}
className={clsx(
'px-6 py-3 text-right text-xs font-medium text-gray-500 dark:text-gray-300 uppercase tracking-wider',
'px-6 py-3 text-xs font-medium text-gray-500 dark:text-gray-300 uppercase tracking-wider',
column.align === 'left' && 'text-left',
column.align === 'center' && 'text-center',
(!column.align || column.align === 'right') && 'text-right',
column.sortable && 'cursor-pointer hover:bg-gray-100 dark:hover:bg-gray-600'
)}
onClick={() => column.sortable && handleSort(column.key)}
>
<div className="flex items-center justify-end space-x-1">
<span>{column.label}</span>
<div className={clsx('flex items-center space-x-1',
column.align === 'left' && 'justify-start',
column.align === 'center' && 'justify-center',
(!column.align || column.align === 'right') && 'justify-end'
)}>
<span style={{ width: '100%', textAlign: 'right' }}>{column.label}</span>
{column.sortable && (
<div className="flex flex-col">
<div className="flex flex-col ml-1">
<ChevronUp
className={clsx(
'h-3 w-3',
@ -119,7 +126,12 @@ export const Table = ({ columns, data, loading = false }: TableProps) => {
{sortedData.map((row, rowIndex) => (
<tr key={rowIndex} className="hover:bg-gray-50 dark:hover:bg-gray-700">
{columns.map((column) => (
<td key={column.key} className="px-6 py-4 whitespace-nowrap text-sm text-gray-900 dark:text-gray-100 text-right">
<td key={column.key} className={clsx(
'px-6 py-4 whitespace-nowrap text-sm text-gray-900 dark:text-gray-100',
column.align === 'left' && 'text-left',
column.align === 'center' && 'text-center',
(!column.align || column.align === 'right') && 'text-right'
)}>
{column.render ? column.render(row[column.key], row) : row[column.key]}
</td>
))}

View File

@ -0,0 +1,111 @@
import React, { useState, KeyboardEvent } from 'react';
import { X } from 'lucide-react';
interface TagInputProps {
values: string[];
onChange: (values: string[]) => void;
placeholder?: string;
label?: string;
error?: string;
disabled?: boolean;
}
export const TagInput: React.FC<TagInputProps> = ({
values,
onChange,
placeholder = "اضافه کنید و Enter بزنید...",
label,
error,
disabled = false,
}) => {
const [inputValue, setInputValue] = useState('');
const addValue = (value: string) => {
const trimmedValue = value.trim();
if (trimmedValue && !values.includes(trimmedValue)) {
onChange([...values, trimmedValue]);
setInputValue('');
}
};
const removeValue = (index: number) => {
const newValues = values.filter((_, i) => i !== index);
onChange(newValues);
};
const handleKeyDown = (e: KeyboardEvent<HTMLInputElement>) => {
if (e.key === 'Enter') {
e.preventDefault();
addValue(inputValue);
} else if (e.key === 'Backspace' && !inputValue && values.length > 0) {
removeValue(values.length - 1);
}
};
const handleInputBlur = () => {
if (inputValue.trim()) {
addValue(inputValue);
}
};
return (
<div className="space-y-2">
{label && (
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300">
{label}
</label>
)}
<div
className={`
w-full min-h-[42px] px-3 py-2 border rounded-md
focus-within:outline-none focus-within:ring-1 focus-within:ring-primary-500
${error ? 'border-red-500' : 'border-gray-300 dark:border-gray-600'}
${disabled ? 'bg-gray-100 cursor-not-allowed' : 'bg-white dark:bg-gray-700'}
`}
>
<div className="flex flex-wrap gap-1 items-center">
{values.map((value, index) => (
<span
key={index}
className="inline-flex items-center gap-1 px-2.5 py-1.5 bg-primary-100 dark:bg-primary-500/30 text-primary-900 dark:text-white text-sm rounded-md border border-primary-200 dark:border-primary-500/60"
>
{value}
{!disabled && (
<button
type="button"
onClick={() => removeValue(index)}
className="hover:bg-primary-200 dark:hover:bg-primary-500/50 rounded-full p-0.5"
>
<X className="h-3 w-3" />
</button>
)}
</span>
))}
{!disabled && (
<input
type="text"
value={inputValue}
onChange={(e) => setInputValue(e.target.value)}
onKeyDown={handleKeyDown}
onBlur={handleInputBlur}
placeholder={values.length === 0 ? placeholder : ""}
className="flex-1 min-w-[120px] border-none outline-none bg-transparent text-sm dark:text-gray-100"
/>
)}
</div>
</div>
{error && (
<p className="text-sm text-red-600 dark:text-red-400">{error}</p>
)}
{!disabled && (
<p className="text-xs text-gray-500 dark:text-gray-400">
Enter بزنید یا روی جای دیگری کلیک کنید تا مقدار اضافه شود
</p>
)}
</div>
);
};

View File

@ -0,0 +1,41 @@
import React from 'react';
interface ToggleSwitchProps {
checked: boolean;
onChange: (checked: boolean) => void;
disabled?: boolean;
className?: string;
}
export const ToggleSwitch: React.FC<ToggleSwitchProps> = ({
checked,
onChange,
disabled = false,
className = '',
}) => {
return (
<label className={`flex items-center cursor-pointer ${disabled ? 'opacity-50 cursor-not-allowed' : ''} ${className}`}>
<input
type="checkbox"
checked={checked}
onChange={(e) => onChange(e.target.checked)}
disabled={disabled}
className="sr-only"
/>
<div
className={`relative w-11 h-6 rounded-full transition-colors ${
checked
? 'bg-primary-600'
: 'bg-gray-300 dark:bg-gray-600'
} ${disabled ? 'opacity-50 cursor-not-allowed' : ''}`}
>
<div
className={`absolute top-0.5 left-0.5 bg-white rounded-full h-5 w-5 transition-transform ${
checked ? 'translate-x-5' : 'translate-x-0'
}`}
/>
</div>
</label>
);
};

View File

@ -0,0 +1,122 @@
import React from 'react';
interface TypographyProps {
children: React.ReactNode;
className?: string;
}
interface LabelProps extends TypographyProps {
htmlFor?: string;
}
// Page Headers
export const PageTitle = ({ children, className = '' }: TypographyProps) => (
<h1 className={`text-2xl sm:text-3xl font-bold text-gray-900 dark:text-gray-100 mb-6 ${className}`}>
{children}
</h1>
);
export const PageSubtitle = ({ children, className = '' }: TypographyProps) => (
<p className={`text-sm sm:text-base text-gray-600 dark:text-gray-400 mt-1 ${className}`}>
{children}
</p>
);
// Section Headers
export const SectionTitle = ({ children, className = '' }: TypographyProps) => (
<h2 className={`text-lg sm:text-xl font-semibold text-gray-900 dark:text-gray-100 ${className}`}>
{children}
</h2>
);
export const SectionSubtitle = ({ children, className = '' }: TypographyProps) => (
<h3 className={`text-base sm:text-lg font-medium text-gray-900 dark:text-gray-100 ${className}`}>
{children}
</h3>
);
// Card Headers
export const CardTitle = ({ children, className = '' }: TypographyProps) => (
<h3 className={`text-base sm:text-lg font-semibold text-gray-900 dark:text-gray-100 ${className}`}>
{children}
</h3>
);
export const CardSubtitle = ({ children, className = '' }: TypographyProps) => (
<p className={`text-sm text-gray-600 dark:text-gray-400 ${className}`}>
{children}
</p>
);
// Stats and Values
export const StatValue = ({ children, className = '' }: TypographyProps) => (
<div className={`text-lg sm:text-xl lg:text-2xl font-semibold text-gray-900 dark:text-gray-100 ${className}`}>
{children}
</div>
);
export const StatLabel = ({ children, className = '' }: TypographyProps) => (
<dt className={`text-xs sm:text-sm font-medium text-gray-500 dark:text-gray-400 truncate ${className}`}>
{children}
</dt>
);
// Body Text
export const BodyText = ({ children, className = '' }: TypographyProps) => (
<p className={`text-sm sm:text-base text-gray-700 dark:text-gray-300 ${className}`}>
{children}
</p>
);
export const SmallText = ({ children, className = '' }: TypographyProps) => (
<p className={`text-xs sm:text-sm text-gray-600 dark:text-gray-400 ${className}`}>
{children}
</p>
);
// Labels
export const Label = ({ children, htmlFor, className = '' }: LabelProps) => (
<label htmlFor={htmlFor} className={`block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1 ${className}`}>
{children}
</label>
);
// Form Headers with Mobile Support
interface FormHeaderProps {
title: string;
subtitle?: string;
backButton?: React.ReactNode;
actions?: React.ReactNode;
className?: string;
}
export const FormHeader = ({ title, subtitle, backButton, actions, className = '' }: FormHeaderProps) => (
<div className={`space-y-3 sm:space-y-4 ${className}`}>
{/* Mobile: Stack vertically, Desktop: Side by side */}
<div className="flex flex-col space-y-3 sm:flex-row sm:items-center sm:gap-4 sm:space-y-0">
{backButton && (
<div className="flex-shrink-0">
{backButton}
</div>
)}
<div className="min-w-0 flex-1">
<PageTitle className="break-words">{title}</PageTitle>
{subtitle && <PageSubtitle className="break-words">{subtitle}</PageSubtitle>}
</div>
</div>
</div>
);
// Page Container with consistent mobile spacing
export const PageContainer = ({ children, className = '' }: TypographyProps) => (
<div className={`space-y-6 max-w-none ${className}`}>
{children}
</div>
);
// Mobile-friendly card container
export const MobileCard = ({ children, className = '' }: TypographyProps) => (
<div className={`card p-3 sm:p-4 lg:p-6 ${className}`}>
{children}
</div>
);

View File

@ -0,0 +1,696 @@
import React, { useState, useEffect } from 'react';
import { Plus, Trash2, Edit3, Package, X, Edit, Image as ImageIcon } from 'lucide-react';
import { ProductVariantFormData, ProductImage } from '../../pages/products/core/_models';
import { Button } from './Button';
import { FileUploader } from './FileUploader';
import { useFileUpload, useFileDelete } from '../../hooks/useFileUpload';
import { persianToEnglish, convertPersianNumbersInObject } from '../../utils/numberUtils';
import { API_GATE_WAY, API_ROUTES } from '@/constant/routes';
import { toast } from "react-hot-toast";
const toPublicUrl = (img: any): ProductImage => {
const rawUrl: string = img?.url || '';
const serveKey: string | undefined = (img && img.serve_key) || undefined;
const url = serveKey
? `${API_GATE_WAY}/${API_ROUTES.DOWNLOAD_FILE(serveKey)}`
: rawUrl?.startsWith('http')
? rawUrl
: rawUrl
? `${API_GATE_WAY}${rawUrl.startsWith('/') ? '' : '/'}${rawUrl}`
: '';
return {
id: (img?.id ?? img).toString(),
url,
alt: img?.alt || '',
order: img?.order ?? 0,
type: img?.mime_type || img?.type,
};
};
const IMAGE_MAX_SIZE = 2 * 1024 * 1024;
const VIDEO_MAX_SIZE = 25 * 1024 * 1024;
const isImageFileType = (file: File) => file.type?.startsWith('image/');
const isVideoFileType = (file: File) => file.type?.startsWith('video/');
const validateVariantMedia = (file: File) => {
if (isImageFileType(file)) {
if (file.size > IMAGE_MAX_SIZE) {
throw new Error('حجم تصویر نباید بیشتر از ۲ مگابایت باشد');
}
} else if (isVideoFileType(file)) {
if (file.size > VIDEO_MAX_SIZE) {
throw new Error('حجم ویدیو نباید بیشتر از ۲۵ مگابایت باشد');
}
} else {
throw new Error('فقط تصاویر یا ویدیو مجاز است');
}
};
interface ProductOption {
id: number;
title: string;
description?: string;
}
interface VariantManagerProps {
variants: ProductVariantFormData[];
onChange: (variants: ProductVariantFormData[]) => void;
disabled?: boolean;
productOptions?: ProductOption[];
variantAttributeName?: string;
}
interface VariantFormProps {
variant?: ProductVariantFormData;
onSave: (variant: ProductVariantFormData) => void;
onCancel: () => void;
isEdit?: boolean;
productOptions?: ProductOption[];
variantAttributeName?: string;
}
const VariantForm: React.FC<VariantFormProps> = ({ variant, onSave, onCancel, isEdit = false, productOptions = [], variantAttributeName }) => {
const [formData, setFormData] = useState<ProductVariantFormData>(
variant || {
enabled: true,
fee_percentage: 0,
profit_percentage: 0,
tax_percentage: 0,
stock_limit: 0,
stock_managed: true,
stock_number: 0,
weight: 0,
attributes: {},
meta: {},
file_ids: []
}
);
const [uploadedImages, setUploadedImages] = useState<ProductImage[]>(
Array.isArray(variant?.file_ids) && variant.file_ids.length > 0 && typeof variant.file_ids[0] === 'object'
? variant.file_ids.map(toPublicUrl)
: []
);
const [variantAttributeValue, setVariantAttributeValue] = useState('');
const [meta, setMeta] = useState<Record<string, any>>(variant?.meta || {});
const [newMetaKey, setNewMetaKey] = useState('');
const [newMetaValue, setNewMetaValue] = useState('');
const [attributeError, setAttributeError] = useState('');
const [weightDisplay, setWeightDisplay] = useState(variant?.weight?.toString() || '');
const [feePercentageDisplay, setFeePercentageDisplay] = useState(variant?.fee_percentage?.toString() || '');
const [profitPercentageDisplay, setProfitPercentageDisplay] = useState(variant?.profit_percentage?.toString() || '');
const [taxPercentageDisplay, setTaxPercentageDisplay] = useState(variant?.tax_percentage?.toString() || '');
const { mutateAsync: uploadFile } = useFileUpload();
const { mutate: deleteFile } = useFileDelete();
// Sync formData.file_ids with uploadedImages
useEffect(() => {
setFormData(prev => ({ ...prev, file_ids: uploadedImages }));
}, [uploadedImages]);
// Sync display states with formData when editing
useEffect(() => {
if (variant?.weight !== undefined) {
setWeightDisplay(variant.weight.toString());
}
if (variant?.fee_percentage !== undefined) {
setFeePercentageDisplay(variant.fee_percentage.toString());
}
if (variant?.profit_percentage !== undefined) {
setProfitPercentageDisplay(variant.profit_percentage.toString());
}
if (variant?.tax_percentage !== undefined) {
setTaxPercentageDisplay(variant.tax_percentage.toString());
}
// Load variant attribute value if exists
if (variantAttributeName && variant?.attributes && variant.attributes[variantAttributeName]) {
setVariantAttributeValue(variant.attributes[variantAttributeName].toString());
}
}, [variant?.weight, variant?.fee_percentage, variant?.profit_percentage, variant?.tax_percentage, variant?.attributes, variantAttributeName]);
const handleInputChange = (field: keyof ProductVariantFormData, value: any) => {
if (typeof value === 'string') {
value = persianToEnglish(value);
}
setFormData(prev => ({ ...prev, [field]: value }));
};
const handleFileUpload = async (file: File) => {
try {
validateVariantMedia(file);
const result = await uploadFile(file);
setUploadedImages(prev => {
const newImage: ProductImage = {
id: result.id,
url: result.url,
alt: file.name,
order: prev.length,
type: result.mimeType || file.type
};
return [...prev, newImage];
});
return result;
} catch (error: any) {
toast.error(error?.message || 'خطا در آپلود فایل');
throw error;
}
};
const handleFileRemove = (fileId: string) => {
const updatedImages = uploadedImages.filter(img => img.id !== fileId);
setUploadedImages(updatedImages);
deleteFile(fileId);
};
const handleAddMeta = () => {
if (newMetaKey.trim() && newMetaValue.trim()) {
const updatedMeta = {
...meta,
[newMetaKey.trim()]: newMetaValue.trim()
};
setMeta(updatedMeta);
setNewMetaKey('');
setNewMetaValue('');
}
};
const handleRemoveMeta = (key: string) => {
const updatedMeta = { ...meta };
delete updatedMeta[key];
setMeta(updatedMeta);
};
const handleSave = () => {
// Reset previous errors
setAttributeError('');
// Validate attribute value when attribute name is defined
if (variantAttributeName && !variantAttributeValue.trim()) {
setAttributeError(`مقدار ${variantAttributeName} الزامی است.`);
return;
}
// نگه داشتن آبجکت کامل تصویر برای نمایش در لیست و حالت ویرایش
const fileObjects = uploadedImages;
// Create attributes object with single key-value pair
const attributes = variantAttributeName && variantAttributeValue.trim()
? { [variantAttributeName]: variantAttributeValue.trim() }
: {};
const convertedData = convertPersianNumbersInObject({
...formData,
attributes,
meta,
file_ids: fileObjects
});
onSave(convertedData);
};
return (
<div className="space-y-6 bg-gray-50 dark:bg-gray-700 p-6 rounded-lg border">
<div>
<h4 className="text-lg font-medium text-gray-900 dark:text-gray-100">
{isEdit ? 'ویرایش Variant' : 'افزودن Variant جدید'}
</h4>
</div>
{/* Basic Info */}
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
درصد کارمزد
</label>
<input
type="text"
inputMode="decimal"
value={feePercentageDisplay}
onChange={(e) => {
const converted = persianToEnglish(e.target.value);
setFeePercentageDisplay(converted);
}}
onBlur={(e) => {
const converted = persianToEnglish(e.target.value);
const numValue = parseFloat(converted) || 0;
handleInputChange('fee_percentage', numValue);
}}
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md focus:outline-none focus:ring-1 focus:ring-primary-500 dark:bg-gray-700 dark:text-gray-100"
placeholder="مثال: ۵.۲"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
درصد سود
</label>
<input
type="text"
inputMode="decimal"
value={profitPercentageDisplay}
onChange={(e) => {
const converted = persianToEnglish(e.target.value);
setProfitPercentageDisplay(converted);
}}
onBlur={(e) => {
const converted = persianToEnglish(e.target.value);
const numValue = parseFloat(converted) || 0;
handleInputChange('profit_percentage', numValue);
}}
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md focus:outline-none focus:ring-1 focus:ring-primary-500 dark:bg-gray-700 dark:text-gray-100"
placeholder="مثال: ۱۰.۵"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
درصد مالیات
</label>
<input
type="text"
inputMode="decimal"
value={taxPercentageDisplay}
onChange={(e) => {
const converted = persianToEnglish(e.target.value);
setTaxPercentageDisplay(converted);
}}
onBlur={(e) => {
const converted = persianToEnglish(e.target.value);
const numValue = parseFloat(converted) || 0;
handleInputChange('tax_percentage', numValue);
}}
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md focus:outline-none focus:ring-1 focus:ring-primary-500 dark:bg-gray-700 dark:text-gray-100"
placeholder="مثال: ۹"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
وزن (گرم)
</label>
<input
type="text"
inputMode="decimal"
value={weightDisplay}
onChange={(e) => {
const converted = persianToEnglish(e.target.value);
setWeightDisplay(converted);
}}
onBlur={(e) => {
const converted = persianToEnglish(e.target.value);
const numValue = parseFloat(converted) || 0;
handleInputChange('weight', numValue);
}}
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md focus:outline-none focus:ring-1 focus:ring-primary-500 dark:bg-gray-700 dark:text-gray-100"
placeholder="مثال: ۱۲۰۰.۵"
/>
</div>
</div>
{/* Stock Management */}
<div>
<h5 className="text-md font-medium text-gray-900 dark:text-gray-100 mb-3">
مدیریت موجودی
</h5>
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
<div className="flex items-center space-x-3 space-x-reverse">
<input
type="checkbox"
checked={formData.stock_managed}
onChange={(e) => handleInputChange('stock_managed', e.target.checked)}
className="w-4 h-4 text-primary-600 bg-gray-100 border-gray-300 rounded focus:ring-primary-500"
/>
<label className="text-sm font-medium text-gray-700 dark:text-gray-300">
مدیریت موجودی فعال باشد
</label>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
تعداد موجودی
</label>
<input
type="text"
inputMode="numeric"
value={formData.stock_number || ''}
onChange={(e) => {
const converted = persianToEnglish(e.target.value);
handleInputChange('stock_number', parseInt(converted) || 0);
}}
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md focus:outline-none focus:ring-1 focus:ring-primary-500 dark:bg-gray-700 dark:text-gray-100"
placeholder="مثال: ۱۰۰"
disabled={!formData.stock_managed}
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
حد موجودی
</label>
<input
type="text"
inputMode="numeric"
value={formData.stock_limit || ''}
onChange={(e) => {
const converted = persianToEnglish(e.target.value);
handleInputChange('stock_limit', parseInt(converted) || 0);
}}
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md focus:outline-none focus:ring-1 focus:ring-primary-500 dark:bg-gray-700 dark:text-gray-100"
placeholder="مثال: ۱۰"
disabled={!formData.stock_managed}
/>
</div>
</div>
</div>
{/* Images */}
<div>
<h5 className="text-md font-medium text-gray-900 dark:text-gray-100 mb-3">
تصاویر Variant
</h5>
<FileUploader
onUpload={handleFileUpload}
onRemove={handleFileRemove}
acceptedTypes={['image/*', 'video/*']}
maxFileSize={25 * 1024 * 1024}
maxFiles={5}
label=""
description="فایل‌های تصویری یا ویدیویی مخصوص این Variant را آپلود کنید"
/>
{uploadedImages.length > 0 && (
<div className="mt-4">
<div className="grid grid-cols-2 md:grid-cols-4 gap-3">
{uploadedImages.map((image, index) => (
<div key={image.id} className="relative group">
{image.type?.startsWith('video') ? (
<video
src={image.url}
className="w-full h-20 object-cover rounded-lg border"
controls
muted
/>
) : (
<img
src={image.url}
alt={image.alt || `تصویر ${index + 1}`}
className="w-full h-20 object-cover rounded-lg border"
/>
)}
<button
type="button"
onClick={() => handleFileRemove(image.id)}
className="absolute -top-1 -right-1 w-5 h-5 bg-red-500 text-white rounded-full flex items-center justify-center opacity-0 group-hover:opacity-100 transition-opacity"
>
×
</button>
</div>
))}
</div>
</div>
)}
</div>
{/* Variant Attribute */}
{variantAttributeName && (
<div>
<h5 className="text-md font-medium text-gray-900 dark:text-gray-100 mb-3">
ویژگی Variant
</h5>
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
{variantAttributeName}
</label>
<input
type="text"
value={variantAttributeValue}
onChange={(e) => setVariantAttributeValue(e.target.value)}
placeholder={`مقدار ${variantAttributeName} را وارد کنید`}
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md focus:outline-none focus:ring-1 focus:ring-primary-500 dark:bg-gray-700 dark:text-gray-100"
/>
{attributeError && (
<p className="text-red-500 text-xs mt-1">{attributeError}</p>
)}
</div>
</div>
)}
{/* Meta Data */}
<div>
<h5 className="text-md font-medium text-gray-900 dark:text-gray-100 mb-3">
Meta Data
</h5>
<div className="flex gap-3 mb-3">
<input
type="text"
value={newMetaKey}
onChange={(e) => setNewMetaKey(e.target.value)}
placeholder="کلید Meta"
className="flex-1 px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md focus:outline-none focus:ring-1 focus:ring-primary-500 dark:bg-gray-700 dark:text-gray-100"
/>
<input
type="text"
value={newMetaValue}
onChange={(e) => setNewMetaValue(e.target.value)}
placeholder="مقدار Meta"
className="flex-1 px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md focus:outline-none focus:ring-1 focus:ring-primary-500 dark:bg-gray-700 dark:text-gray-100"
/>
<Button
type="button"
variant="secondary"
onClick={handleAddMeta}
className="flex items-center gap-2"
>
<Plus className="h-4 w-4" />
افزودن
</Button>
</div>
{Object.keys(meta).length > 0 && (
<div className="grid grid-cols-1 md:grid-cols-2 gap-2">
{Object.entries(meta).map(([key, value]) => (
<div key={key} className="flex items-center justify-between bg-white dark:bg-gray-600 px-3 py-2 rounded-md border">
<span className="text-sm">
<strong>{key}:</strong> {String(value)}
</span>
<button
type="button"
onClick={() => handleRemoveMeta(key)}
className="text-red-500 hover:text-red-700"
>
<Trash2 className="h-3 w-3" />
</button>
</div>
))}
</div>
)}
</div>
{/* Status */}
<div className="flex items-center space-x-3 space-x-reverse">
<input
type="checkbox"
checked={formData.enabled}
onChange={(e) => handleInputChange('enabled', e.target.checked)}
className="w-4 h-4 text-primary-600 bg-gray-100 border-gray-300 rounded focus:ring-primary-500"
/>
<label className="text-sm font-medium text-gray-700 dark:text-gray-300">
Variant فعال باشد
</label>
</div>
{/* Action Buttons */}
<div className="flex justify-end gap-3 pt-4 border-t border-gray-200 dark:border-gray-600">
<Button variant="secondary" onClick={onCancel}>
انصراف
</Button>
<Button onClick={handleSave}>
{isEdit ? 'به‌روزرسانی' : 'افزودن'}
</Button>
</div>
</div>
);
};
export const VariantManager: React.FC<VariantManagerProps> = ({ variants, onChange, disabled = false, productOptions = [], variantAttributeName }) => {
const [showForm, setShowForm] = useState(false);
const [editingIndex, setEditingIndex] = useState<number | null>(null);
const handleAddVariant = () => {
setEditingIndex(null);
setShowForm(true);
};
const handleEditVariant = (index: number) => {
setEditingIndex(index);
setShowForm(true);
};
const handleDeleteVariant = (index: number) => {
const updatedVariants = variants.filter((_, i) => i !== index);
onChange(updatedVariants);
};
const handleSaveVariant = (variant: ProductVariantFormData) => {
if (editingIndex !== null) {
// Edit existing variant
const updatedVariants = [...variants];
updatedVariants[editingIndex] = variant;
onChange(updatedVariants);
} else {
// Add new variant
onChange([...variants, variant]);
}
setShowForm(false);
setEditingIndex(null);
};
const handleCancelForm = () => {
setShowForm(false);
setEditingIndex(null);
};
return (
<div className="space-y-4">
<div className="flex items-center justify-between">
<h3 className="text-lg font-medium text-gray-900 dark:text-gray-100">
Variants محصول ({variants.length})
</h3>
{!disabled && !showForm && (
<Button onClick={handleAddVariant} className="flex items-center gap-2">
<Plus className="h-4 w-4" />
افزودن Variant
</Button>
)}
</div>
{/* Show Form */}
{showForm && (
<VariantForm
variant={editingIndex !== null ? variants[editingIndex] : undefined}
onSave={handleSaveVariant}
onCancel={handleCancelForm}
isEdit={editingIndex !== null}
productOptions={productOptions}
variantAttributeName={variantAttributeName}
/>
)}
{/* Variants List */}
{variants.length > 0 && (
<div className="space-y-3">
{variants.map((variant, index) => (
<div key={index} className="bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded-lg p-4">
<div className="flex items-center justify-between">
<div className="flex-1">
<div className="flex items-center gap-4 mb-2">
<h4 className="font-medium text-gray-900 dark:text-gray-100">
Variant {index + 1}
</h4>
<span className={`inline-flex items-center px-2 py-1 rounded-full text-xs font-medium ${variant.enabled ? 'bg-green-100 text-green-800' : 'bg-red-100 text-red-800'
}`}>
{variant.enabled ? 'فعال' : 'غیرفعال'}
</span>
</div>
<div className="grid grid-cols-2 md:grid-cols-4 gap-4 text-sm text-gray-600 dark:text-gray-400">
<div>
<strong>درصد کارمزد:</strong> {variant.fee_percentage}%
</div>
<div>
<strong>درصد سود:</strong> {variant.profit_percentage}%
</div>
<div>
<strong>درصد مالیات:</strong> {variant.tax_percentage}%
</div>
<div>
<strong>موجودی:</strong> {variant.stock_managed ? `${variant.stock_number} عدد` : 'بدون محدودیت'}
</div>
<div>
<strong>وزن:</strong> {parseFloat(variant.weight.toString()).toLocaleString('fa-IR')} گرم
</div>
</div>
{variant.file_ids && variant.file_ids.length > 0 && (
<div className="flex gap-2 mt-3">
{variant.file_ids.slice(0, 3).map((image, imgIndex) => (
<img
key={image.id}
src={image.url}
alt={image.alt || `تصویر ${imgIndex + 1}`}
className="w-12 h-12 object-cover rounded border"
/>
))}
{variant.file_ids.length > 3 && (
<div className="w-12 h-12 bg-gray-100 dark:bg-gray-600 rounded border flex items-center justify-center text-xs">
+{variant.file_ids.length - 3}
</div>
)}
</div>
)}
{/* Show Attributes if any */}
{Object.keys(variant.attributes).length > 0 && (
<div className="mt-2">
<div className="text-xs text-gray-500 dark:text-gray-400 mb-1">ویژگیها:</div>
<div className="flex flex-wrap gap-1">
{Object.entries(variant.attributes).map(([key, value]) => (
<span key={key} className="inline-flex items-center px-2 py-1 rounded-full text-xs bg-blue-100 text-blue-800">
{key}: {String(value)}
</span>
))}
</div>
</div>
)}
</div>
{!disabled && (
<div className="flex gap-2">
<button
type="button"
onClick={() => handleEditVariant(index)}
className="p-2 text-blue-600 hover:bg-blue-50 dark:hover:bg-blue-900/20 rounded-md"
title="ویرایش"
>
<Edit3 className="h-4 w-4" />
</button>
<button
type="button"
onClick={() => handleDeleteVariant(index)}
className="p-2 text-red-600 hover:bg-red-50 dark:hover:bg-red-900/20 rounded-md"
title="حذف"
>
<Trash2 className="h-4 w-4" />
</button>
</div>
)}
</div>
</div>
))}
</div>
)}
{variants.length === 0 && !showForm && (
<div className="text-center py-8 bg-gray-50 dark:bg-gray-700 rounded-lg border-2 border-dashed border-gray-300 dark:border-gray-600">
<Package className="h-12 w-12 text-gray-400 mx-auto mb-4" />
<p className="text-gray-500 dark:text-gray-400 mb-4">
هنوز هیچ Variant ای اضافه نشده
</p>
{!disabled && (
<Button onClick={handleAddVariant} className="flex items-center gap-2 mx-auto">
<Plus className="h-4 w-4" />
افزودن اولین Variant
</Button>
)}
</div>
)}
</div>
);
};

View File

@ -0,0 +1 @@
export const pageSize = 10;

168
src/constant/routes.ts Normal file
View File

@ -0,0 +1,168 @@
export const API_GATE_WAY = "https://apimznstg.aireview.ir";
export const ADMIN_API_PREFIX = "api/v1/admin";
export const REQUEST_TIMEOUT = 30000;
export const API_ROUTES = {
// Auth APIs
ADMIN_LOGIN: "auth/login",
// Draft APIs (non-admin)
GET_DISCOUNT_DETAIL: (id: string) => `api/v1/discount-drafts/${id}`,
GET_DRAFT_DETAIL: (id: string) => `api/v1/drafts/${id}`,
// Admin Users APIs
GET_ADMIN_USERS: "admin-users",
GET_ADMIN_USER: (id: string) => `admin-users/${id}`,
CREATE_ADMIN_USER: "admin-users",
UPDATE_ADMIN_USER: (id: string) => `admin-users/${id}`,
DELETE_ADMIN_USER: (id: string) => `admin-users/${id}`,
// Roles APIs
GET_ROLES: "roles",
GET_ROLE: (id: string) => `roles/${id}`,
CREATE_ROLE: "roles",
UPDATE_ROLE: (id: string) => `roles/${id}`,
DELETE_ROLE: (id: string) => `roles/${id}`,
GET_ROLE_PERMISSIONS: (id: string) => `roles/${id}/permissions`,
ASSIGN_ROLE_PERMISSION: (roleId: string, permissionId: string) =>
`roles/${roleId}/permissions/${permissionId}`,
REMOVE_ROLE_PERMISSION: (roleId: string, permissionId: string) =>
`roles/${roleId}/permissions/${permissionId}`,
// Permissions APIs
GET_PERMISSIONS: "permissions",
GET_PERMISSION: (id: string) => `permissions/${id}`,
CREATE_PERMISSION: "permissions",
UPDATE_PERMISSION: (id: string) => `permissions/${id}`,
DELETE_PERMISSION: (id: string) => `permissions/${id}`,
// Product Options APIs (non-admin)
GET_PRODUCT_OPTIONS: "products/options",
GET_PRODUCT_OPTION: (id: string) => `products/options/${id}`,
CREATE_PRODUCT_OPTION: "products/options",
UPDATE_PRODUCT_OPTION: (id: string) => `products/options/${id}`,
DELETE_PRODUCT_OPTION: (id: string) => `products/options/${id}`,
// Categories APIs (non-admin)
GET_CATEGORIES: "api/v1/products/categories",
GET_CATEGORY: (id: string) => `api/v1/products/categories/${id}`,
CREATE_CATEGORY: "api/v1/products/categories",
UPDATE_CATEGORY: (id: string) => `api/v1/products/categories/${id}`,
DELETE_CATEGORY: (id: string) => `api/v1/products/categories/${id}`,
// Products APIs (non-admin)
GET_PRODUCTS: "api/v1/products",
GET_PRODUCT: (id: string) => `api/v1/products/${id}`,
CREATE_PRODUCT: "api/v1/products",
UPDATE_PRODUCT: (id: string) => `products/${id}`,
DELETE_PRODUCT: (id: string) => `api/v1/products/${id}`,
GET_PRODUCT_VARIANTS: (id: string) => `api/v1/products/${id}/variants`,
CREATE_PRODUCT_VARIANT: (id: string) => `api/v1/products/${id}/variants`,
UPDATE_PRODUCT_VARIANT: (variantId: string) =>
`api/v1/products/variants/${variantId}`,
DELETE_PRODUCT_VARIANT: (variantId: string) =>
`api/v1/products/variants/${variantId}`,
// Files APIs
GET_FILES: "files",
UPLOAD_FILE: "files",
GET_FILE: (id: string) => `files/${id}`,
UPDATE_FILE: (id: string) => `files/${id}`,
DELETE_FILE: (id: string) => `files/${id}`,
DOWNLOAD_FILE: (serveKey: string) => `api/v1/files/${serveKey}`, // non-admin
// Images APIs (non-admin)
GET_IMAGES: "api/v1/images",
CREATE_IMAGE: "api/v1/images",
UPDATE_IMAGE: (imageId: string) => `api/v1/products/images/${imageId}`,
DELETE_IMAGE: (imageId: string) => `api/v1/products/images/${imageId}`,
// Landing Hero APIs
GET_LANDING_HERO: "settings/landing/hero", // non-admin
UPDATE_LANDING_HERO: "settings/landing/hero", // admin
// Discount Codes APIs
GET_DISCOUNT_CODES: "discount/",
GET_DISCOUNT_CODE: (id: string) => `discount/${id}/`,
CREATE_DISCOUNT_CODE: "discount/",
UPDATE_DISCOUNT_CODE: (id: string) => `discount/${id}/`,
DELETE_DISCOUNT_CODE: (id: string) => `discount/${id}/`,
// Orders APIs
GET_ORDERS: "checkout/orders",
GET_ORDER: (id: string) => `checkout/orders/${id}`,
GET_ORDER_STATS: "checkout/orders/statistics",
UPDATE_ORDER_STATUS: (id: string) => `checkout/orders/${id}/status`,
// Shipping Methods APIs
GET_SHIPPING_METHODS: "checkout/shipping-methods",
GET_SHIPPING_METHOD: (id: string) => `checkout/shipping-methods/${id}`,
CREATE_SHIPPING_METHOD: "checkout/shipping-methods",
UPDATE_SHIPPING_METHOD: (id: string) => `checkout/shipping-methods/${id}`,
DELETE_SHIPPING_METHOD: (id: string) => `checkout/shipping-methods/${id}`,
// User Admin APIs
GET_USERS: "users",
GET_USER: (id: string) => `users/${id}`,
SEARCH_USERS: "users/search",
CREATE_USER: "users",
UPDATE_USER: (id: string) => `users/${id}`,
UPDATE_USER_PROFILE: (id: string) => `users/${id}/profile`,
UPDATE_USER_AVATAR: (id: string) => `users/${id}/avatar`,
DELETE_USER: (id: string) => `users/${id}`,
VERIFY_USER: (id: string) => `users/${id}/verify`,
UNVERIFY_USER: (id: string) => `users/${id}/unverify`,
GET_TICKETS: "tickets",
GET_TICKET: (id: string) => `tickets/${id}`,
CREATE_TICKET_REPLY: (id: string) => `tickets/${id}/messages`,
UPDATE_TICKET_STATUS: (id: string) => `tickets/${id}/status`,
ASSIGN_TICKET: (id: string) => `tickets/${id}/assign`,
GET_TICKET_DEPARTMENTS: "tickets/config/departments",
GET_TICKET_DEPARTMENT: (id: string) => `tickets/config/departments/${id}`,
CREATE_TICKET_DEPARTMENT: "tickets/config/departments",
UPDATE_TICKET_DEPARTMENT: (id: string) => `tickets/config/departments/${id}`,
DELETE_TICKET_DEPARTMENT: (id: string) => `tickets/config/departments/${id}`,
GET_TICKET_STATUSES: "tickets/config/statuses",
GET_TICKET_STATUS: (id: string) => `tickets/config/statuses/${id}`,
CREATE_TICKET_STATUS: "tickets/config/statuses",
UPDATE_TICKET_STATUS_CONFIG: (id: string) => `tickets/config/statuses/${id}`,
DELETE_TICKET_STATUS: (id: string) => `tickets/config/statuses/${id}`,
GET_TICKET_SUBJECTS: "tickets/config/subjects",
GET_TICKET_SUBJECT: (id: string) => `tickets/config/subjects/${id}`,
CREATE_TICKET_SUBJECT: "tickets/config/subjects",
UPDATE_TICKET_SUBJECT: (id: string) => `tickets/config/subjects/${id}`,
DELETE_TICKET_SUBJECT: (id: string) => `tickets/config/subjects/${id}`,
// Contact Us APIs
GET_CONTACT_US_MESSAGES: "contact-us",
DELETE_CONTACT_US_MESSAGE: (id: string) => `contact-us/${id}`,
// Payment IPG APIs
GET_IPG_STATUS: "payment/ipg/status",
UPDATE_IPG_STATUS: "payment/ipg/status",
// Payment Card APIs
GET_PAYMENT_CARD: "payment/card",
UPDATE_PAYMENT_CARD: "payment/card",
// Wallet APIs
GET_WALLET_STATUS: "wallet/status",
UPDATE_WALLET_STATUS: "wallet/status",
// Reports APIs
DISCOUNT_REPORTS: "reports/discounts",
DISCOUNT_USAGE_REPORT: "reports/discounts/usage",
CUSTOMER_DISCOUNT_USAGE_REPORT: "reports/discounts/customer-usage",
PAYMENT_METHODS_REPORT: "reports/payments/methods",
PAYMENT_TRANSACTIONS_REPORT: "reports/payments/transactions",
SHIPMENTS_BY_METHOD_REPORT: "reports/shipments/by-method",
SALES_GROWTH_REPORT: "reports/sales/growth",
USER_REGISTRATION_GROWTH_REPORT: "reports/user-registration/growth",
SALES_BY_CATEGORY_REPORT: "reports/sales/by-category",
// Product Comments APIs
GET_PRODUCT_COMMENTS: "products/comments",
UPDATE_COMMENT_STATUS: (commentId: string) => `products/comments/${commentId}/status`,
DELETE_COMMENT: (commentId: string) => `products/comments/${commentId}`,
};

View File

@ -1,41 +1,58 @@
import { createContext, useContext, useReducer, useEffect } from 'react';
import { AuthState, User } from '../types';
import React, { createContext, useContext, useReducer, useEffect } from 'react';
import { AuthState, AdminUser, Permission } from '../types/auth';
import toast from 'react-hot-toast';
interface AuthContextType extends AuthState {
login: (email: string, password: string) => Promise<boolean>;
interface AuthContextType {
isAuthenticated: boolean;
isLoading: boolean;
user: AdminUser | null;
permissions: Permission[];
allPermissions: Permission[];
token: string | null;
refreshToken: string | null;
logout: () => void;
hasPermission: (permission: number) => boolean;
restoreSession: () => void;
hasPermission: (permissionId: number) => boolean;
hasPermissionByTitle: (title: string) => boolean;
}
const AuthContext = createContext<AuthContextType | undefined>(undefined);
type AuthAction =
| { type: 'LOGIN_SUCCESS'; payload: { user: User; token: string } }
| { type: 'LOGIN'; payload: { user: AdminUser; permissions: Permission[]; allPermissions: Permission[]; token: string; refreshToken: string } }
| { type: 'LOGOUT' }
| { type: 'RESTORE_SESSION'; payload: { user: User; token: string } };
| { type: 'RESTORE_SESSION'; payload: { user: AdminUser; permissions: Permission[]; allPermissions: Permission[]; token: string; refreshToken: string } }
| { type: 'SET_LOADING'; payload: boolean };
const authReducer = (state: AuthState, action: AuthAction): AuthState => {
switch (action.type) {
case 'LOGIN_SUCCESS':
case 'LOGIN':
case 'RESTORE_SESSION':
return {
...state,
isAuthenticated: true,
isLoading: false,
user: action.payload.user,
permissions: action.payload.user.permissions,
permissions: action.payload.permissions,
allPermissions: action.payload.allPermissions,
token: action.payload.token,
refreshToken: action.payload.refreshToken,
};
case 'LOGOUT':
return {
...state,
isAuthenticated: false,
isLoading: false,
user: null,
permissions: [],
allPermissions: [],
token: null,
refreshToken: null,
};
case 'RESTORE_SESSION':
case 'SET_LOADING':
return {
isAuthenticated: true,
user: action.payload.user,
permissions: action.payload.user.permissions,
token: action.payload.token,
...state,
isLoading: action.payload,
};
default:
return state;
@ -44,79 +61,90 @@ const authReducer = (state: AuthState, action: AuthAction): AuthState => {
const initialState: AuthState = {
isAuthenticated: false,
isLoading: true,
user: null,
permissions: [],
allPermissions: [],
token: null,
refreshToken: null,
};
export const AuthProvider = ({ children }: { children: React.ReactNode }) => {
const [state, dispatch] = useReducer(authReducer, initialState);
useEffect(() => {
const token = localStorage.getItem('admin_token');
const userStr = localStorage.getItem('admin_user');
const restoreSession = () => {
dispatch({ type: 'SET_LOADING', payload: true });
if (token && userStr) {
const token = localStorage.getItem('admin_token');
const refreshToken = localStorage.getItem('admin_refresh_token');
const userStr = localStorage.getItem('admin_user');
const permissionsStr = localStorage.getItem('admin_permissions');
if (token && userStr && permissionsStr) {
try {
const user = JSON.parse(userStr);
dispatch({ type: 'RESTORE_SESSION', payload: { user, token } });
} catch (error) {
localStorage.removeItem('admin_token');
localStorage.removeItem('admin_user');
}
}
}, []);
const login = async (email: string, password: string): Promise<boolean> => {
try {
const mockUser: User = {
id: '1',
name: 'مدیر کل',
email: email,
role: 'admin',
permissions: [1, 2, 3, 4, 5, 10, 15, 20, 22, 25, 30],
status: 'active',
createdAt: new Date().toISOString(),
lastLogin: new Date().toISOString(),
};
const mockToken = 'mock-jwt-token-' + Date.now();
if (email === 'admin@test.com' && password === 'admin123') {
localStorage.setItem('admin_token', mockToken);
localStorage.setItem('admin_user', JSON.stringify(mockUser));
const permissions = JSON.parse(permissionsStr);
dispatch({
type: 'LOGIN_SUCCESS',
payload: { user: mockUser, token: mockToken }
});
return true;
type: 'RESTORE_SESSION',
payload: {
user,
permissions,
allPermissions: permissions,
token,
refreshToken: refreshToken || ''
}
return false;
});
} catch (error) {
console.error('Login error:', error);
return false;
localStorage.removeItem('admin_token');
localStorage.removeItem('admin_refresh_token');
localStorage.removeItem('admin_user');
localStorage.removeItem('admin_permissions');
dispatch({ type: 'SET_LOADING', payload: false });
}
} else {
dispatch({ type: 'SET_LOADING', payload: false });
}
};
useEffect(() => {
restoreSession();
}, []);
const logout = () => {
localStorage.removeItem('admin_token');
localStorage.removeItem('admin_refresh_token');
localStorage.removeItem('admin_user');
localStorage.removeItem('admin_permissions');
dispatch({ type: 'LOGOUT' });
toast.success('خروج موفقیت‌آمیز بود');
};
const hasPermission = (permission: number): boolean => {
return state.permissions.includes(permission);
const hasPermission = (permissionId: number): boolean => {
// اگر Super Admin (id=1) باشد، به همه چیز دسترسی دارد
const isSuperAdmin = state.permissions.some(permission => permission.id === 1);
if (isSuperAdmin) return true;
// در غیر اینصورت چک کن permission مورد نیاز را دارد یا نه
return state.permissions.some(permission => permission.id === permissionId);
};
const hasPermissionByTitle = (title: string): boolean => {
// اگر Super Admin (AdminAll) باشد، به همه چیز دسترسی دارد
const isSuperAdmin = state.permissions.some(permission => permission.title === "AdminAll");
if (isSuperAdmin) return true;
// در غیر اینصورت چک کن permission مورد نیاز را دارد یا نه
return state.permissions.some(permission => permission.title === title);
};
return (
<AuthContext.Provider value={{
...state,
login,
logout,
restoreSession,
hasPermission,
hasPermissionByTitle,
}}>
{children}
</AuthContext.Provider>

View File

@ -12,13 +12,14 @@ export const ThemeProvider = ({ children }: { children: any }) => {
useEffect(() => {
const savedTheme = localStorage.getItem('admin_theme') as 'light' | 'dark' | null;
const prefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches;
const initialTheme = savedTheme || (prefersDark ? 'dark' : 'light');
const initialTheme = savedTheme || 'light';
setMode(initialTheme);
if (initialTheme === 'dark') {
document.documentElement.classList.add('dark');
} else {
document.documentElement.classList.remove('dark');
}
}, []);

View File

@ -0,0 +1,75 @@
import { useMutation } from "@tanstack/react-query";
import { toast } from "react-hot-toast";
import { APIUrlGenerator } from "@/utils/baseHttpService";
import { API_ROUTES } from "@/constant/routes";
import { httpPostRequest, httpDeleteRequest } from "@/utils/baseHttpService";
interface UploadResponse {
file: {
id: number;
url: string;
name: string;
original_name: string;
serve_key: string;
size: number;
mime_type: string;
created_at: string;
updated_at: string;
};
}
export const useFileUpload = () => {
return useMutation({
mutationFn: async (file: File): Promise<{ id: string; url: string; mimeType?: string }> => {
const formData = new FormData();
formData.append("file", file);
formData.append("name", "uploaded-file");
console.log("Uploading file:", file.name);
const response = await httpPostRequest<UploadResponse>(
APIUrlGenerator(API_ROUTES.UPLOAD_FILE),
formData,
{
headers: {
"Content-Type": "multipart/form-data",
},
}
);
console.log("Upload response:", response);
if (!response.data?.file) {
throw new Error("Invalid upload response");
}
return {
id: response.data.file.id.toString(),
url: response.data.file.url,
mimeType: response.data.file.mime_type,
};
},
onError: (error: any) => {
console.error("File upload error:", error);
toast.error(error?.message || "خطا در آپلود فایل");
},
});
};
export const useFileDelete = () => {
return useMutation({
mutationFn: async (fileId: string) => {
const response = await httpDeleteRequest(
APIUrlGenerator(API_ROUTES.DELETE_FILE(fileId))
);
return response.data;
},
onSuccess: () => {
toast.success("فایل با موفقیت حذف شد");
},
onError: (error: any) => {
console.error("File delete error:", error);
toast.error(error?.message || "خطا در حذف فایل");
},
});
};

View File

@ -78,7 +78,7 @@ export const useCreateUser = () => {
return useMutation({
mutationFn: (userData: CreateUserRequest) =>
userService.createUser(userData),
onSuccess: (response) => {
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["users"] });
toast.success("کاربر با موفقیت ایجاد شد");
},
@ -95,7 +95,7 @@ export const useUpdateUser = () => {
return useMutation({
mutationFn: ({ id, data }: { id: string; data: UpdateUserRequest }) =>
userService.updateUser(id, data),
onSuccess: (response, variables) => {
onSuccess: (_, variables) => {
queryClient.invalidateQueries({ queryKey: ["users"] });
queryClient.invalidateQueries({ queryKey: ["user", variables.id] });
toast.success("کاربر با موفقیت ویرایش شد");

View File

@ -30,16 +30,36 @@
body {
background-color: #f9fafb;
transition: background-color 0.2s ease;
/* Prevent horizontal scrolling on mobile */
overflow-x: hidden;
}
.dark body {
background-color: #111827;
}
/* Ensure touch targets are large enough on mobile */
@media (max-width: 1024px) {
button,
a,
[role="button"] {
min-height: 44px;
min-width: 44px;
}
}
/* Improve text selection on mobile */
@media (max-width: 768px) {
* {
-webkit-text-size-adjust: 100%;
-webkit-tap-highlight-color: transparent;
}
}
}
@layer components {
.card {
@apply bg-white dark:bg-gray-800 rounded-lg shadow-sm border border-gray-200 dark:border-gray-700;
@apply bg-white dark:bg-gray-800 rounded-xl shadow-md border border-gray-200 dark:border-gray-700 transition-shadow duration-200 hover:shadow-lg;
}
.btn-primary {
@ -53,4 +73,76 @@
.input {
@apply w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 focus:ring-2 focus:ring-primary-500 focus:border-transparent transition-colors duration-200;
}
/* Mobile-specific utilities */
.mobile-container {
@apply px-4 sm:px-6 lg:px-8;
}
.mobile-card {
@apply card p-4 sm:p-6;
}
/* Safe area for mobile devices */
.safe-area {
padding-left: env(safe-area-inset-left);
padding-right: env(safe-area-inset-right);
padding-top: env(safe-area-inset-top);
padding-bottom: env(safe-area-inset-bottom);
}
/* Mobile-specific form improvements */
@media (max-width: 768px) {
.input,
textarea,
select {
@apply text-base; /* Prevent zoom on iOS */
font-size: 16px !important;
}
.form-grid {
@apply grid-cols-1 gap-4;
}
.button-group {
@apply flex-col space-y-3 space-x-0;
}
.button-group > * {
@apply w-full;
}
}
/* Responsive text utilities */
.text-responsive-xs {
@apply text-xs sm:text-sm;
}
.text-responsive-sm {
@apply text-sm sm:text-base;
}
.text-responsive-base {
@apply text-base sm:text-lg;
}
.text-responsive-lg {
@apply text-lg sm:text-xl lg:text-2xl;
}
.text-responsive-xl {
@apply text-xl sm:text-2xl lg:text-3xl;
}
/* Mobile chart container */
.chart-container {
@apply w-full overflow-hidden;
min-height: 200px;
}
@media (max-width: 640px) {
.chart-container {
min-height: 180px;
}
}
}

View File

@ -1,27 +1,17 @@
import { QueryClient } from "@tanstack/react-query";
import toast from "react-hot-toast";
export const queryClient = new QueryClient({
defaultOptions: {
queries: {
retry: (failureCount, error: any) => {
if (error?.response?.status === 404) return false;
if (error?.response?.status === 403) return false;
if (error?.response?.status === 401) return false;
return failureCount < 2;
},
staleTime: 5 * 60 * 1000, // 5 minutes
refetchOnWindowFocus: false,
gcTime: 0,
staleTime: 0,
refetchOnMount: true,
refetchOnReconnect: true,
refetchOnWindowFocus: true,
retry: 1,
},
mutations: {
onError: (error: any) => {
const message =
error?.response?.data?.message ||
error?.message ||
"خطایی رخ داده است";
toast.error(message);
},
retry: 1,
},
},
});

View File

@ -1,159 +1,135 @@
import { Users, ShoppingBag, DollarSign, TrendingUp } from 'lucide-react';
import { StatsCard } from '../components/dashboard/StatsCard';
import { BarChart } from '../components/charts/BarChart';
import { LineChart } from '../components/charts/LineChart';
import { ApexAreaChartCard } from '../components/charts/ApexAreaChartCard';
import { ApexBarChartCard } from '../components/charts/ApexBarChartCard';
import { useMemo } from 'react';
import { useNavigate } from 'react-router-dom';
import { useSalesGrowthReport, useUserRegistrationGrowthReport, useSalesByCategoryReport } from './reports/sales-statistics/core/_hooks';
import { useOrders } from './orders/core/_hooks';
import { StatusBadge } from '../components/ui/StatusBadge';
import { formatCurrency, formatDate } from '../utils/formatters';
import { PieChart } from '../components/charts/PieChart';
import { Table } from '../components/ui/Table';
import { Button } from '../components/ui/Button';
import { PermissionWrapper } from '../components/common/PermissionWrapper';
import { PageContainer, PageTitle, CardTitle } from '../components/ui/Typography';
import { ChartData, TableColumn } from '../types';
const statsData = [
export const Dashboard = () => {
const navigate = useNavigate();
const { data: salesGrowthReport } = useSalesGrowthReport({ group_by: 'month' });
const { data: registrationGrowthReport } = useUserRegistrationGrowthReport({ group_by: 'month' });
const { data: salesByCategoryReport } = useSalesByCategoryReport();
const recentOrdersFilters = useMemo(() => ({
page: 1,
limit: 5,
status: 'pending' as const,
}), []);
const { data: recentOrders, isLoading: isOrdersLoading } = useOrders(recentOrdersFilters);
const monthlySalesData: ChartData[] = useMemo(() => {
return (salesGrowthReport?.sales || []).map((item) => ({
name: item.month_name || `${item.year}/${item.month}`,
value: item.total_sales,
}));
}, [salesGrowthReport]);
const registrationGrowthData: ChartData[] = useMemo(() => {
return (registrationGrowthReport?.registrations || []).map((item) => ({
name: item.month_name || `${item.year}/${item.month}`,
value: item.total_users,
}));
}, [registrationGrowthReport]);
const salesByCategoryData: ChartData[] = useMemo(() => {
return (salesByCategoryReport?.categories || []).map((item) => ({
name: item.category_name,
value: item.percentage,
}));
}, [salesByCategoryReport]);
const orderColumns: TableColumn[] = [
{
title: 'کل کاربران',
value: 1247,
change: 12,
icon: Users,
color: 'blue',
key: 'order_number',
label: 'شماره سفارش',
render: (value: string) => `#${value}`,
},
{
title: 'فروش ماهانه',
value: '۲۴,۵۶۷,۰۰۰',
change: 8.5,
icon: DollarSign,
color: 'green',
key: 'customer',
label: 'مشتری',
render: (_value, row: any) => {
const customer = row.user || row.customer;
const name = `${customer?.first_name || ''} ${customer?.last_name || ''}`.trim();
return name || 'نامشخص';
},
},
{
title: 'کل سفارشات',
value: 356,
change: -2.3,
icon: ShoppingBag,
color: 'yellow',
key: 'final_total',
label: 'مبلغ',
render: (_value, row: any) => formatCurrency(row.final_total || row.total_amount || 0),
},
{
title: 'رشد فروش',
value: '۲۳.۵%',
change: 15.2,
icon: TrendingUp,
color: 'purple',
},
];
const chartData: ChartData[] = [
{ name: 'فروردین', value: 4000 },
{ name: 'اردیبهشت', value: 3000 },
{ name: 'خرداد', value: 5000 },
{ name: 'تیر', value: 4500 },
{ name: 'مرداد', value: 6000 },
{ name: 'شهریور', value: 5500 },
];
const pieData: ChartData[] = [
{ name: 'دسکتاپ', value: 45 },
{ name: 'موبایل', value: 35 },
{ name: 'تبلت', value: 20 },
];
const recentUsers = [
{ id: 1, name: 'علی احمدی', email: 'ali@example.com', role: 'کاربر', status: 'فعال', createdAt: '۱۴۰۲/۰۸/۱۵' },
{ id: 2, name: 'فاطمه حسینی', email: 'fateme@example.com', role: 'مدیر', status: 'فعال', createdAt: '۱۴۰۲/۰۸/۱۴' },
{ id: 3, name: 'محمد رضایی', email: 'mohammad@example.com', role: 'کاربر', status: 'غیرفعال', createdAt: '۱۴۰۲/۰۸/۱۳' },
{ id: 4, name: 'زهرا کریمی', email: 'zahra@example.com', role: 'کاربر', status: 'فعال', createdAt: '۱۴۰۲/۰۸/۱۲' },
];
const userColumns: TableColumn[] = [
{ key: 'name', label: 'نام', sortable: true },
{ key: 'email', label: 'ایمیل' },
{ key: 'role', label: 'نقش' },
{
key: 'status',
label: 'وضعیت',
render: (value) => (
<span className={`px-2 py-1 rounded-full text-xs font-medium ${value === 'فعال'
? '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'
}`}>
{value}
</span>
)
render: (value: any) => <StatusBadge status={value} type="order" />,
},
{ key: 'createdAt', label: 'تاریخ عضویت' },
{
key: 'actions',
label: 'عملیات',
render: (_, row) => (
<div className="flex space-x-2">
<Button size="sm" variant="secondary">
ویرایش
</Button>
<PermissionWrapper permission={22}>
<Button size="sm" variant="danger">
حذف
</Button>
</PermissionWrapper>
</div>
)
}
key: 'created_at',
label: 'تاریخ',
render: (value: string) => formatDate(value),
},
];
export const Dashboard = () => {
const ordersTableData = (recentOrders?.orders || []).map((item) => item.order ?? item);
return (
<div className="p-6 space-y-6">
<div className="flex items-center justify-between">
<h1 className="text-2xl font-bold text-gray-900 dark:text-gray-100">
داشبورد
</h1>
<div className="flex space-x-4">
<Button variant="secondary">
گزارشگیری
</Button>
<PermissionWrapper permission={25}>
<Button>
اضافه کردن
</Button>
</PermissionWrapper>
</div>
<PageContainer>
{/* Header with mobile-responsive layout */}
<div className="flex flex-col space-y-3 sm:flex-row sm:items-center sm:justify-between sm:space-y-0">
<PageTitle>داشبورد</PageTitle>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6">
{statsData.map((stat, index) => (
<StatsCard key={index} {...stat} />
))}
</div>
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
<BarChart
data={chartData}
{/* Charts - Better mobile layout */}
<div className="grid grid-cols-1 xl:grid-cols-2 gap-4 sm:gap-6">
<div className="min-w-0">
<ApexBarChartCard
data={monthlySalesData}
title="فروش ماهانه"
color="#3b82f6"
/>
<LineChart
data={chartData}
title="روند رشد"
</div>
<div className="min-w-0">
<ApexAreaChartCard
data={registrationGrowthData}
title="روند رشد ثبت‌نام کاربران"
color="#10b981"
/>
</div>
</div>
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
<div className="lg:col-span-2">
<div className="card p-6">
<h3 className="text-lg font-semibold text-gray-900 dark:text-gray-100 mb-4">
کاربران اخیر
</h3>
<Table
columns={userColumns}
data={recentUsers}
/>
{/* Table and Pie Chart - Mobile responsive */}
<div className="grid grid-cols-1 xl:grid-cols-3 gap-4 sm:gap-6">
<div className="xl:col-span-2 min-w-0">
<div className="card p-3 sm:p-4 lg:p-6">
<CardTitle className="mb-3 sm:mb-4">
آخرین سفارشات در انتظار
</CardTitle>
<div className="overflow-x-auto">
<Table columns={orderColumns} data={ordersTableData} loading={isOrdersLoading} />
</div>
<div className="mt-4 flex justify-end">
<Button variant="secondary" onClick={() => navigate('/orders')}>
مشاهده همه
</Button>
</div>
</div>
<div>
</div>
<div className="min-w-0">
<PieChart
data={pieData}
title="دستگاه‌های کاربری"
data={salesByCategoryData}
title="توزیع فروش بر اساس دسته‌بندی"
colors={['#3b82f6', '#10b981', '#f59e0b']}
/>
</div>
</div>
</div>
</PageContainer>
);
};

View File

@ -1,19 +1,22 @@
import { useState } from 'react';
import { Navigate } from 'react-router-dom';
import { Eye, EyeOff, Lock, Mail } from 'lucide-react';
import { Navigate, useNavigate } from 'react-router-dom';
import { Eye, EyeOff, Lock, User } from 'lucide-react';
import { useForm } from 'react-hook-form';
import { yupResolver } from '@hookform/resolvers/yup';
import { useAuth } from '../contexts/AuthContext';
import { Button } from '../components/ui/Button';
import { Input } from '../components/ui/Input';
import { loginSchema, LoginFormData } from '../utils/validationSchemas';
import { useLogin } from './auth/core/_hooks';
export const Login = () => {
const { isAuthenticated, login } = useAuth();
const { isAuthenticated, isLoading, restoreSession } = useAuth();
const navigate = useNavigate();
const [showPassword, setShowPassword] = useState(false);
const [loading, setLoading] = useState(false);
const [error, setError] = useState('');
const { mutate: login, isPending: isLoggingIn } = useLogin();
const {
register,
handleSubmit,
@ -23,24 +26,33 @@ export const Login = () => {
mode: 'onChange',
});
if (isLoading) {
return (
<div className="min-h-screen flex items-center justify-center bg-gray-50 dark:bg-gray-900">
<div className="text-center">
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-primary-600 mx-auto"></div>
<p className="mt-4 text-gray-600 dark:text-gray-400">در حال بارگذاری...</p>
</div>
</div>
);
}
if (isAuthenticated) {
return <Navigate to="/" replace />;
}
const onSubmit = async (data: LoginFormData) => {
setLoading(true);
setError('');
try {
const success = await login(data.email, data.password);
if (!success) {
setError('ایمیل یا رمز عبور اشتباه است');
}
} catch (error) {
setError('خطایی رخ داده است. لطفا دوباره تلاش کنید');
} finally {
setLoading(false);
login(data, {
onSuccess: () => {
restoreSession();
navigate('/');
},
onError: () => {
setError('نام کاربری یا رمز عبور اشتباه است');
}
});
};
return (
@ -61,12 +73,12 @@ export const Login = () => {
<form className="mt-8 space-y-6" onSubmit={handleSubmit(onSubmit)}>
<div className="space-y-4">
<Input
label="ایمیل"
type="email"
placeholder="admin@test.com"
icon={Mail}
error={errors.email?.message}
{...register('email')}
label="نام کاربری"
type="text"
placeholder="نام کاربری خود را وارد کنید"
icon={User}
error={errors.username?.message}
{...register('username')}
/>
<div className="space-y-1">
@ -79,7 +91,7 @@ export const Login = () => {
</div>
<input
type={showPassword ? 'text' : 'password'}
placeholder="admin123"
placeholder="رمز عبور خود را وارد کنید"
className={`input pr-10 pl-10 ${errors.password ? 'border-red-500 dark:border-red-500 focus:ring-red-500' : ''
}`}
{...register('password')}
@ -110,15 +122,9 @@ export const Login = () => {
</div>
)}
<div className="bg-blue-50 dark:bg-blue-900/20 border border-blue-200 dark:border-blue-800 text-blue-600 dark:text-blue-400 px-4 py-3 rounded-lg text-sm">
<p className="font-medium">اطلاعات تست:</p>
<p>ایمیل: admin@test.com</p>
<p>رمز عبور: admin123</p>
</div>
<Button
type="submit"
loading={loading}
loading={isLoggingIn}
disabled={!isValid}
className="w-full"
>

View File

@ -1,7 +1,11 @@
import { useState } from 'react';
import { Bell, Check, X, Plus, Search, Filter, AlertCircle, Info, CheckCircle, XCircle } from 'lucide-react';
import { Plus, Search, Filter, Bell, BellOff, Clock, Eye } from 'lucide-react';
import { Table } from '../components/ui/Table';
import { Button } from '../components/ui/Button';
import { Pagination } from '../components/ui/Pagination';
import { PermissionWrapper } from '../components/common/PermissionWrapper';
import { TableColumn } from '../types';
import { PageContainer, PageTitle, StatValue } from '../components/ui/Typography';
const allNotifications = [
{
@ -96,13 +100,13 @@ export const Notifications = () => {
const getNotificationIcon = (type: string) => {
switch (type) {
case 'error':
return <XCircle className="h-5 w-5 text-red-600" />;
return <BellOff className="h-5 w-5 text-red-600" />;
case 'warning':
return <AlertCircle className="h-5 w-5 text-yellow-600" />;
return <Bell className="h-5 w-5 text-yellow-600" />;
case 'success':
return <CheckCircle className="h-5 w-5 text-green-600" />;
return <Bell className="h-5 w-5 text-green-600" />;
case 'info':
return <Info className="h-5 w-5 text-blue-600" />;
return <Eye className="h-5 w-5 text-blue-600" />;
default:
return <Bell className="h-5 w-5 text-gray-600" />;
}
@ -156,16 +160,11 @@ export const Notifications = () => {
const unreadCount = notifications.filter(n => !n.isRead).length;
return (
<div className="p-6 space-y-6">
<div className="flex flex-col sm:flex-row items-start sm:items-center justify-between gap-4">
<div>
<h1 className="text-2xl font-bold text-gray-900 dark:text-gray-100">
اعلانات
</h1>
<p className="text-gray-600 dark:text-gray-400 mt-1">
<PageContainer>
<PageTitle>اعلانات</PageTitle>
<StatValue>
{unreadCount} اعلان خوانده نشده از {notifications.length} اعلان
</p>
</div>
</StatValue>
<div className="flex items-center space-x-4">
<Button
@ -173,7 +172,7 @@ export const Notifications = () => {
onClick={handleMarkAllAsRead}
disabled={unreadCount === 0}
>
<Check className="h-4 w-4 ml-2" />
<BellOff className="h-4 w-4 ml-2" />
همه را خوانده شده علامت بزن
</Button>
<Button>
@ -181,7 +180,6 @@ export const Notifications = () => {
اعلان جدید
</Button>
</div>
</div>
<div className="grid grid-cols-1 md:grid-cols-4 gap-6">
<div className="bg-white dark:bg-gray-800 p-4 rounded-lg shadow">
@ -189,41 +187,41 @@ export const Notifications = () => {
<Bell className="h-8 w-8 text-blue-600" />
<div className="mr-3">
<p className="text-sm font-medium text-gray-600 dark:text-gray-400">کل اعلانات</p>
<p className="text-2xl font-bold text-gray-900 dark:text-gray-100">{notifications.length}</p>
<StatValue>{notifications.length}</StatValue>
</div>
</div>
</div>
<div className="bg-white dark:bg-gray-800 p-4 rounded-lg shadow">
<div className="flex items-center">
<AlertCircle className="h-8 w-8 text-red-600" />
<BellOff className="h-8 w-8 text-red-600" />
<div className="mr-3">
<p className="text-sm font-medium text-gray-600 dark:text-gray-400">خوانده نشده</p>
<p className="text-2xl font-bold text-gray-900 dark:text-gray-100">{unreadCount}</p>
<StatValue>{unreadCount}</StatValue>
</div>
</div>
</div>
<div className="bg-white dark:bg-gray-800 p-4 rounded-lg shadow">
<div className="flex items-center">
<XCircle className="h-8 w-8 text-red-600" />
<BellOff className="h-8 w-8 text-red-600" />
<div className="mr-3">
<p className="text-sm font-medium text-gray-600 dark:text-gray-400">خطا</p>
<p className="text-2xl font-bold text-gray-900 dark:text-gray-100">
<StatValue>
{notifications.filter(n => n.type === 'error').length}
</p>
</StatValue>
</div>
</div>
</div>
<div className="bg-white dark:bg-gray-800 p-4 rounded-lg shadow">
<div className="flex items-center">
<AlertCircle className="h-8 w-8 text-yellow-600" />
<Bell className="h-8 w-8 text-yellow-600" />
<div className="mr-3">
<p className="text-sm font-medium text-gray-600 dark:text-gray-400">هشدار</p>
<p className="text-2xl font-bold text-gray-900 dark:text-gray-100">
<StatValue>
{notifications.filter(n => n.type === 'warning').length}
</p>
</StatValue>
</div>
</div>
</div>
@ -308,7 +306,7 @@ export const Notifications = () => {
variant="secondary"
onClick={() => handleMarkAsRead(notification.id)}
>
<Check className="h-4 w-4" />
<BellOff className="h-4 w-4" />
</Button>
)}
<Button
@ -316,7 +314,7 @@ export const Notifications = () => {
variant="danger"
onClick={() => handleDeleteNotification(notification.id)}
>
<X className="h-4 w-4" />
<BellOff className="h-4 w-4" />
</Button>
</div>
</div>
@ -339,6 +337,6 @@ export const Notifications = () => {
totalItems={filteredNotifications.length}
/>
</div>
</div>
</PageContainer>
);
};

View File

@ -1,204 +0,0 @@
import { useState } from 'react';
import { Search, Filter, ShoppingCart, TrendingUp } from 'lucide-react';
import { Table } from '../components/ui/Table';
import { Button } from '../components/ui/Button';
import { Pagination } from '../components/ui/Pagination';
import { TableColumn } from '../types';
const allOrders = [
{ id: 1001, customer: 'علی احمدی', products: '۳ محصول', amount: '۴۵,۰۰۰,۰۰۰', status: 'تحویل شده', date: '۱۴۰۲/۰۸/۱۵' },
{ id: 1002, customer: 'فاطمه حسینی', products: '۱ محصول', amount: '۲۵,۰۰۰,۰۰۰', status: 'در حال پردازش', date: '۱۴۰۲/۰۸/۱۴' },
{ id: 1003, customer: 'محمد رضایی', products: '۲ محصول', amount: '۳۲,۰۰۰,۰۰۰', status: 'ارسال شده', date: '۱۴۰۲/۰۸/۱۳' },
{ id: 1004, customer: 'زهرا کریمی', products: '۵ محصول', amount: '۱۲۰,۰۰۰,۰۰۰', status: 'تحویل شده', date: '۱۴۰۲/۰۸/۱۲' },
{ id: 1005, customer: 'حسن نوری', products: '۱ محصول', amount: '۱۸,۰۰۰,۰۰۰', status: 'لغو شده', date: '۱۴۰۲/۰۸/۱۱' },
{ id: 1006, customer: 'مریم صادقی', products: '۴ محصول', amount: '۸۵,۰۰۰,۰۰۰', status: 'در حال پردازش', date: '۱۴۰۲/۰۸/۱۰' },
{ id: 1007, customer: 'احمد قاسمی', products: '۲ محصول', amount: '۳۸,۰۰۰,۰۰۰', status: 'ارسال شده', date: '۱۴۰۲/۰۸/۰۹' },
{ id: 1008, customer: 'سارا محمدی', products: '۳ محصول', amount: '۶۲,۰۰۰,۰۰۰', status: 'تحویل شده', date: '۱۴۰۲/۰۸/۰۸' },
{ id: 1009, customer: 'رضا کریمی', products: '۱ محصول', amount: '۱۵,۰۰۰,۰۰۰', status: 'در حال پردازش', date: '۱۴۰۲/۰۸/۰۷' },
{ id: 1010, customer: 'نرگس احمدی', products: '۶ محصول', amount: '۱۴۵,۰۰۰,۰۰۰', status: 'تحویل شده', date: '۱۴۰۲/۰۸/۰۶' },
];
export const Orders = () => {
const [searchTerm, setSearchTerm] = useState('');
const [loading, setLoading] = useState(false);
const [currentPage, setCurrentPage] = useState(1);
const itemsPerPage = 6;
const columns: TableColumn[] = [
{ key: 'id', label: 'شماره سفارش', sortable: true },
{ key: 'customer', label: 'مشتری', sortable: true },
{ key: 'products', label: 'محصولات' },
{
key: 'amount',
label: 'مبلغ',
render: (value) => (
<span className="font-medium text-gray-900 dark:text-gray-100">
{value} تومان
</span>
)
},
{
key: 'status',
label: 'وضعیت',
render: (value) => (
<span className={`px-2 py-1 rounded-full text-xs font-medium ${value === 'تحویل شده'
? 'bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200'
: value === 'ارسال شده'
? 'bg-blue-100 text-blue-800 dark:bg-blue-900 dark:text-blue-200'
: value === 'در حال پردازش'
? 'bg-yellow-100 text-yellow-800 dark:bg-yellow-900 dark:text-yellow-200'
: 'bg-red-100 text-red-800 dark:bg-red-900 dark:text-red-200'
}`}>
{value}
</span>
)
},
{ key: 'date', label: 'تاریخ سفارش', sortable: true },
{
key: 'actions',
label: 'عملیات',
render: (_, row) => (
<div className="flex space-x-2">
<Button
size="sm"
variant="secondary"
onClick={() => handleViewOrder(row)}
>
مشاهده
</Button>
<Button
size="sm"
variant="primary"
onClick={() => handleEditOrder(row)}
>
ویرایش
</Button>
</div>
)
}
];
const filteredOrders = allOrders.filter((order: any) =>
order.customer.toLowerCase().includes(searchTerm.toLowerCase()) ||
order.id.toString().includes(searchTerm)
);
const totalPages = Math.ceil(filteredOrders.length / itemsPerPage);
const startIndex = (currentPage - 1) * itemsPerPage;
const paginatedOrders = filteredOrders.slice(startIndex, startIndex + itemsPerPage);
const handleViewOrder = (order: any) => {
console.log('Viewing order:', order);
};
const handleEditOrder = (order: any) => {
console.log('Editing order:', order);
};
const totalRevenue = allOrders.reduce((sum, order) => {
const amount = parseInt(order.amount.replace(/[,]/g, ''));
return sum + amount;
}, 0);
return (
<div className="p-6 space-y-6">
<div className="flex flex-col sm:flex-row items-start sm:items-center justify-between gap-4">
<div>
<h1 className="text-2xl font-bold text-gray-900 dark:text-gray-100">
مدیریت سفارشات
</h1>
<p className="text-gray-600 dark:text-gray-400 mt-1">
{filteredOrders.length} سفارش یافت شد
</p>
</div>
<div className="flex items-center space-x-4">
<Button variant="secondary">
<Filter className="h-4 w-4 ml-2" />
فیلتر
</Button>
</div>
</div>
<div className="grid grid-cols-1 md:grid-cols-4 gap-6 mb-6">
<div className="bg-white dark:bg-gray-800 p-4 rounded-lg shadow">
<div className="flex items-center">
<ShoppingCart className="h-8 w-8 text-blue-600" />
<div className="mr-3">
<p className="text-sm font-medium text-gray-600 dark:text-gray-400">کل سفارشات</p>
<p className="text-2xl font-bold text-gray-900 dark:text-gray-100">{allOrders.length}</p>
</div>
</div>
</div>
<div className="bg-white dark:bg-gray-800 p-4 rounded-lg shadow">
<div className="flex items-center">
<TrendingUp className="h-8 w-8 text-green-600" />
<div className="mr-3">
<p className="text-sm font-medium text-gray-600 dark:text-gray-400">تحویل شده</p>
<p className="text-2xl font-bold text-gray-900 dark:text-gray-100">
{allOrders.filter(o => o.status === 'تحویل شده').length}
</p>
</div>
</div>
</div>
<div className="bg-white dark:bg-gray-800 p-4 rounded-lg shadow">
<div className="flex items-center">
<ShoppingCart className="h-8 w-8 text-yellow-600" />
<div className="mr-3">
<p className="text-sm font-medium text-gray-600 dark:text-gray-400">در انتظار</p>
<p className="text-2xl font-bold text-gray-900 dark:text-gray-100">
{allOrders.filter(o => o.status === 'در حال پردازش').length}
</p>
</div>
</div>
</div>
<div className="bg-white dark:bg-gray-800 p-4 rounded-lg shadow">
<div className="flex items-center">
<TrendingUp className="h-8 w-8 text-purple-600" />
<div className="mr-3">
<p className="text-sm font-medium text-gray-600 dark:text-gray-400">کل فروش</p>
<p className="text-xl font-bold text-gray-900 dark:text-gray-100">
{totalRevenue.toLocaleString()} تومان
</p>
</div>
</div>
</div>
</div>
<div className="card p-6">
<div className="mb-6">
<div className="relative">
<div className="absolute inset-y-0 right-0 pr-3 flex items-center pointer-events-none">
<Search className="h-5 w-5 text-gray-400" />
</div>
<input
type="text"
placeholder="جستجو در سفارشات..."
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
className="input pr-10 max-w-md"
/>
</div>
</div>
<div className="bg-white dark:bg-gray-800 rounded-lg overflow-hidden">
<Table
columns={columns}
data={paginatedOrders}
loading={loading}
/>
<Pagination
currentPage={currentPage}
totalPages={totalPages}
onPageChange={setCurrentPage}
itemsPerPage={itemsPerPage}
totalItems={filteredOrders.length}
/>
</div>
</div>
</div>
);
};

View File

@ -1,10 +1,12 @@
import { useState } from 'react';
import { Plus, Search, Filter, Package } from 'lucide-react';
import { useNavigate } from 'react-router-dom';
import { Package, Plus, Search, Filter, Eye, Edit, Trash2, Grid, List } from 'lucide-react';
import { Table } from '../components/ui/Table';
import { Button } from '../components/ui/Button';
import { Pagination } from '../components/ui/Pagination';
import { PermissionWrapper } from '../components/common/PermissionWrapper';
import { TableColumn } from '../types';
import { PageContainer, PageTitle, StatValue } from '../components/ui/Typography';
const allProducts = [
{ id: 1, name: 'لپ‌تاپ ایسوس', category: 'کامپیوتر', price: '۲۵,۰۰۰,۰۰۰', stock: 15, status: 'موجود', createdAt: '۱۴۰۲/۰۸/۱۵' },
@ -19,7 +21,6 @@ const allProducts = [
const Products = () => {
const [searchTerm, setSearchTerm] = useState('');
const [loading, setLoading] = useState(false);
const [currentPage, setCurrentPage] = useState(1);
const itemsPerPage = 6;
@ -104,16 +105,11 @@ const Products = () => {
};
return (
<div className="p-6 space-y-6">
<div className="flex flex-col sm:flex-row items-start sm:items-center justify-between gap-4">
<div>
<h1 className="text-2xl font-bold text-gray-900 dark:text-gray-100">
مدیریت محصولات
</h1>
<PageContainer>
<PageTitle>مدیریت محصولات</PageTitle>
<p className="text-gray-600 dark:text-gray-400 mt-1">
{filteredProducts.length} محصول یافت شد
</p>
</div>
<div className="flex items-center space-x-4">
<Button variant="secondary">
@ -127,7 +123,6 @@ const Products = () => {
</Button>
</PermissionWrapper>
</div>
</div>
<div className="grid grid-cols-1 md:grid-cols-3 gap-6 mb-6">
<div className="bg-white dark:bg-gray-800 p-4 rounded-lg shadow">
@ -135,7 +130,7 @@ const Products = () => {
<Package className="h-8 w-8 text-blue-600" />
<div className="mr-3">
<p className="text-sm font-medium text-gray-600 dark:text-gray-400">کل محصولات</p>
<p className="text-2xl font-bold text-gray-900 dark:text-gray-100">{allProducts.length}</p>
<StatValue>{allProducts.length}</StatValue>
</div>
</div>
</div>
@ -145,9 +140,9 @@ const Products = () => {
<Package className="h-8 w-8 text-green-600" />
<div className="mr-3">
<p className="text-sm font-medium text-gray-600 dark:text-gray-400">محصولات موجود</p>
<p className="text-2xl font-bold text-gray-900 dark:text-gray-100">
<StatValue>
{allProducts.filter(p => p.status === 'موجود').length}
</p>
</StatValue>
</div>
</div>
</div>
@ -157,9 +152,9 @@ const Products = () => {
<Package className="h-8 w-8 text-red-600" />
<div className="mr-3">
<p className="text-sm font-medium text-gray-600 dark:text-gray-400">محصولات ناموجود</p>
<p className="text-2xl font-bold text-gray-900 dark:text-gray-100">
<StatValue>
{allProducts.filter(p => p.status === 'ناموجود').length}
</p>
</StatValue>
</div>
</div>
</div>
@ -185,7 +180,7 @@ const Products = () => {
<Table
columns={columns}
data={paginatedProducts}
loading={loading}
loading={false}
/>
<Pagination
currentPage={currentPage}
@ -196,7 +191,7 @@ const Products = () => {
/>
</div>
</div>
</div>
</PageContainer>
);
};

View File

@ -1,8 +1,10 @@
import { useState } from 'react';
import { FileText, Download, Calendar, TrendingUp, Users, ShoppingBag, DollarSign } from 'lucide-react';
import { FileText, Download, TrendingUp, Users, ShoppingBag, DollarSign } from 'lucide-react';
import { Button } from '../components/ui/Button';
import { BarChart } from '../components/charts/BarChart';
import { LineChart } from '../components/charts/LineChart';
import { lazy, Suspense } from 'react';
const LineChart = lazy(() => import('../components/charts/LineChart').then(module => ({ default: module.LineChart })));
export const Reports = () => {
const [selectedPeriod, setSelectedPeriod] = useState('month');
@ -164,7 +166,9 @@ export const Reports = () => {
<h3 className="text-lg font-semibold text-gray-900 dark:text-gray-100 mb-4">
رشد کاربران
</h3>
<Suspense fallback={<div className="card p-6 animate-pulse bg-gray-100 dark:bg-gray-800 h-64" />}>
<LineChart data={userGrowthData} />
</Suspense>
</div>
</div>

View File

@ -4,6 +4,7 @@ import { yupResolver } from '@hookform/resolvers/yup';
import { Settings as SettingsIcon, Save, Globe, Mail } from 'lucide-react';
import { Input } from '../components/ui/Input';
import { Button } from '../components/ui/Button';
import { PageHeader } from '../components/layout/PageHeader';
import { settingsSchema, SettingsFormData } from '../utils/validationSchemas';
export const Settings = () => {
@ -43,15 +44,11 @@ export const Settings = () => {
return (
<div className="p-6 max-w-4xl mx-auto">
<div className="mb-8">
<h1 className="text-2xl font-bold text-gray-900 dark:text-gray-100 flex items-center">
<SettingsIcon className="h-6 w-6 ml-3" />
تنظیمات سیستم
</h1>
<p className="text-gray-600 dark:text-gray-400 mt-2">
تنظیمات کلی سیستم را اینجا مدیریت کنید
</p>
</div>
<PageHeader
title="تنظیمات سیستم"
subtitle="تنظیمات کلی سیستم را اینجا مدیریت کنید"
icon={SettingsIcon}
/>
<div className="grid grid-cols-1 lg:grid-cols-3 gap-8">
<div className="lg:col-span-2">

View File

@ -1,5 +1,5 @@
import { useState } from 'react';
import { Plus, Search, Filter } from 'lucide-react';
import { Plus, Search, Filter, Users as UsersIcon, UserCheck, UserX } from 'lucide-react';
import { Table } from '../components/ui/Table';
import { Button } from '../components/ui/Button';
import { Modal } from '../components/ui/Modal';
@ -8,6 +8,7 @@ import { UserForm } from '../components/forms/UserForm';
import { PermissionWrapper } from '../components/common/PermissionWrapper';
import { TableColumn } from '../types';
import { UserFormData } from '../utils/validationSchemas';
import { PageContainer, PageTitle, StatValue } from '../components/ui/Typography';
const allUsers = [
{ id: 1, name: 'علی احمدی', email: 'ali@example.com', role: 'کاربر', status: 'فعال', createdAt: '۱۴۰۲/۰۸/۱۵', phone: '۰۹۱۲۳۴۵۶۷۸۹' },
@ -26,7 +27,6 @@ const allUsers = [
export const Users = () => {
const [searchTerm, setSearchTerm] = useState('');
const [loading, setLoading] = useState(false);
const [showUserModal, setShowUserModal] = useState(false);
const [editingUser, setEditingUser] = useState<any>(null);
const [currentPage, setCurrentPage] = useState(1);
@ -112,27 +112,28 @@ export const Users = () => {
};
return (
<div className="p-6 space-y-6">
<PageContainer>
<div className="flex flex-col sm:flex-row items-start sm:items-center justify-between gap-4">
<div>
<h1 className="text-2xl font-bold text-gray-900 dark:text-gray-100">
مدیریت کاربران
</h1>
<PageTitle>مدیریت کاربران</PageTitle>
<p className="text-gray-600 dark:text-gray-400 mt-1">
{filteredUsers.length} کاربر یافت شد
</p>
</div>
<div className="flex items-center space-x-4">
<div className="flex items-center space-x-3 space-x-reverse">
<Button variant="secondary">
<Filter className="h-4 w-4 ml-2" />
فیلتر
</Button>
<PermissionWrapper permission={25}>
<Button onClick={handleAddUser}>
<Plus className="h-4 w-4 ml-2" />
افزودن کاربر
</Button>
<button
onClick={handleAddUser}
className="flex items-center justify-center w-12 h-12 bg-primary-600 hover:bg-primary-700 rounded-full transition-colors duration-200 text-white shadow-lg hover:shadow-xl"
title="افزودن کاربر"
>
<Plus className="h-5 w-5" />
</button>
</PermissionWrapper>
</div>
</div>
@ -157,7 +158,7 @@ export const Users = () => {
<Table
columns={columns}
data={paginatedUsers}
loading={loading}
loading={false}
/>
<Pagination
currentPage={currentPage}
@ -170,6 +171,7 @@ export const Users = () => {
</div>
<Modal
title={editingUser ? "ویرایش کاربر" : "افزودن کاربر"}
isOpen={showUserModal}
onClose={handleCloseModal}
size="lg"
@ -178,10 +180,10 @@ export const Users = () => {
initialData={editingUser}
onSubmit={handleSubmitUser}
onCancel={handleCloseModal}
loading={loading}
loading={false}
isEdit={!!editingUser}
/>
</Modal>
</div>
</PageContainer>
);
};

View File

@ -6,11 +6,11 @@ import { Modal } from '../components/ui/Modal';
import { Pagination } from '../components/ui/Pagination';
import { UserForm } from '../components/forms/UserForm';
import { PermissionWrapper } from '../components/common/PermissionWrapper';
import { LoadingSpinner } from '../components/ui/LoadingSpinner';
import { TableColumn } from '../types';
import { UserFormData } from '../utils/validationSchemas';
import { formatDate } from '../utils/formatters';
import { useUsers, useCreateUser, useUpdateUser, useDeleteUser } from '../hooks/useUsers';
import { useToast } from '../contexts/ToastContext';
import { useFilters } from '../stores/useAppStore';
const Users = () => {
@ -20,7 +20,6 @@ const Users = () => {
const itemsPerPage = 5;
const { filters, setFilters } = useFilters();
const toast = useToast();
const queryParams = {
page: currentPage,
@ -60,7 +59,7 @@ const Users = () => {
key: 'createdAt',
label: 'تاریخ عضویت',
sortable: true,
render: (value) => new Date(value).toLocaleDateString('fa-IR')
render: (value) => formatDate(value)
},
{
key: 'actions',
@ -177,19 +176,20 @@ const Users = () => {
</p>
</div>
<div className="flex items-center space-x-4">
<div className="flex items-center space-x-3 space-x-reverse">
<Button variant="secondary">
<Filter className="h-4 w-4 ml-2" />
فیلتر
</Button>
<PermissionWrapper permission={25}>
<Button
<button
onClick={handleAddUser}
disabled={createUserMutation.isPending}
className="flex items-center justify-center w-12 h-12 bg-primary-600 hover:bg-primary-700 disabled:opacity-50 disabled:cursor-not-allowed rounded-full transition-colors duration-200 text-white shadow-lg hover:shadow-xl"
title="افزودن کاربر"
>
<Plus className="h-4 w-4 ml-2" />
افزودن کاربر
</Button>
<Plus className="h-5 w-5" />
</button>
</PermissionWrapper>
</div>
</div>
@ -211,7 +211,9 @@ const Users = () => {
</div>
{isLoading ? (
<LoadingSpinner />
<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={[]} loading={true} />
</div>
) : (
<>
<div className="bg-white dark:bg-gray-800 rounded-lg overflow-hidden">
@ -233,6 +235,7 @@ const Users = () => {
</div>
<Modal
title={editingUser ? "ویرایش کاربر" : "افزودن کاربر"}
isOpen={showUserModal}
onClose={handleCloseModal}
size="lg"

View File

@ -0,0 +1,238 @@
import { useParams, useNavigate } from 'react-router-dom';
import { ArrowRight, Shield, Users, Key, Edit, Calendar, FileText, User } from 'lucide-react';
import { Button } from '../../../components/ui/Button';
import { useAdminUser } from '../core/_hooks';
import { PermissionWrapper } from '../../../components/common/PermissionWrapper';
import { PageContainer, PageTitle, SectionTitle, SectionSubtitle, BodyText } from '../../../components/ui/Typography';
import { formatDate } from '../../../utils/formatters';
const AdminUserDetailPage = () => {
const navigate = useNavigate();
const { id = "" } = useParams();
const { data: user, isLoading, error } = useAdminUser(id);
if (isLoading) {
return (
<PageContainer>
<div className="space-y-6 animate-pulse">
<div className="flex items-center justify-between">
<div className="h-8 bg-gray-200 dark:bg-gray-700 rounded w-48"></div>
<div className="h-10 bg-gray-200 dark:bg-gray-700 rounded w-24"></div>
</div>
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
<div className="lg:col-span-2 space-y-6">
{[...Array(3)].map((_, i) => (
<div key={i} className="card p-6">
<div className="h-6 bg-gray-200 dark:bg-gray-700 rounded w-32 mb-4"></div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
{[...Array(4)].map((_, j) => (
<div key={j}>
<div className="h-4 bg-gray-200 dark:bg-gray-700 rounded w-20 mb-2"></div>
<div className="h-4 bg-gray-200 dark:bg-gray-700 rounded w-32"></div>
</div>
))}
</div>
</div>
))}
</div>
<div className="space-y-6">
{[...Array(2)].map((_, i) => (
<div key={i} className="card p-6">
<div className="h-6 bg-gray-200 dark:bg-gray-700 rounded w-24 mb-4"></div>
<div className="space-y-3">
{[...Array(3)].map((_, j) => (
<div key={j} className="h-4 bg-gray-200 dark:bg-gray-700 rounded"></div>
))}
</div>
</div>
))}
</div>
</div>
</div>
</PageContainer>
);
}
if (error) return <div className="text-red-600">خطا در بارگذاری اطلاعات کاربر</div>;
if (!user) return <div>کاربر یافت نشد</div>;
const getStatusBadge = (status: string) => {
const isActive = status === 'active';
return (
<span className={`px-3 py-1 rounded-full text-sm font-medium ${isActive
? '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'
}`}>
{isActive ? 'فعال' : 'غیرفعال'}
</span>
);
};
return (
<PageContainer>
<div className="flex items-center justify-between mb-6">
<div className="flex items-center gap-3">
<button
onClick={() => navigate('/admin-users')}
className="flex items-center justify-center w-10 h-10 rounded-lg bg-gray-100 hover:bg-gray-200 dark:bg-gray-700 dark:hover:bg-gray-600 transition-colors"
>
<ArrowRight className="h-5 w-5" />
</button>
<div>
<PageTitle>جزئیات کاربر ادمین</PageTitle>
<p className="text-gray-600 dark:text-gray-400">نمایش اطلاعات کامل کاربر ادمین</p>
</div>
</div>
<div className="flex gap-3">
<PermissionWrapper permission={23}>
<Button
onClick={() => navigate(`/admin-users/${id}/edit`)}
className="flex items-center gap-2"
>
<Edit className="h-4 w-4" />
ویرایش
</Button>
</PermissionWrapper>
</div>
</div>
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
<div className="lg:col-span-2 space-y-6">
<div className="bg-white dark:bg-gray-800 shadow-sm border border-gray-200 dark:border-gray-700 rounded-lg p-6">
<SectionTitle className="flex items-center gap-2 mb-4">
<User className="h-5 w-5" />
اطلاعات اصلی
</SectionTitle>
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
نام
</label>
<BodyText>{user.first_name || 'تعریف نشده'}</BodyText>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
نام خانوادگی
</label>
<BodyText>{user.last_name || 'تعریف نشده'}</BodyText>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
نام کاربری
</label>
<BodyText>{user.username}</BodyText>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
وضعیت
</label>
{getStatusBadge(user.status)}
</div>
</div>
</div>
{user.roles && user.roles.length > 0 && (
<div className="bg-white dark:bg-gray-800 shadow-sm border border-gray-200 dark:border-gray-700 rounded-lg p-6">
<SectionTitle className="flex items-center gap-2 mb-4">
<Shield className="h-5 w-5" />
نقشها
</SectionTitle>
<div className="flex flex-wrap gap-2">
{user.roles.map((role: any) => (
<span
key={role.id}
className="px-3 py-1 bg-blue-100 text-blue-800 dark:bg-blue-900 dark:text-blue-200 rounded-full text-sm font-medium"
>
{role.title}
</span>
))}
</div>
</div>
)}
{user.permissions && user.permissions.length > 0 && (
<div className="bg-white dark:bg-gray-800 shadow-sm border border-gray-200 dark:border-gray-700 rounded-lg p-6">
<SectionTitle className="flex items-center gap-2 mb-4">
<Key className="h-5 w-5" />
دسترسیهای مستقیم
</SectionTitle>
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
{user.permissions.map((permission: any) => (
<div
key={permission.id}
className="p-3 bg-gray-50 dark:bg-gray-700 rounded-lg"
>
<div className="font-medium text-gray-900 dark:text-gray-100">
{permission.title}
</div>
<div className="text-sm text-gray-600 dark:text-gray-400">
{permission.description}
</div>
</div>
))}
</div>
</div>
)}
</div>
<div className="space-y-6">
<div className="bg-white dark:bg-gray-800 shadow-sm border border-gray-200 dark:border-gray-700 rounded-lg p-6">
<SectionTitle className="flex items-center gap-2 mb-4">
<Calendar className="h-5 w-5" />
اطلاعات زمانی
</SectionTitle>
<div className="space-y-4">
<div>
<SectionSubtitle className="text-sm text-gray-600 dark:text-gray-400 mb-1">
تاریخ ایجاد
</SectionSubtitle>
<BodyText>
{user.created_at ? formatDate(user.created_at) : 'تعریف نشده'}
</BodyText>
</div>
<div>
<SectionSubtitle className="text-sm text-gray-600 dark:text-gray-400 mb-1">
آخرین بروزرسانی
</SectionSubtitle>
<BodyText>
{user.updated_at ? formatDate(user.updated_at) : 'تعریف نشده'}
</BodyText>
</div>
</div>
</div>
<div className="bg-white dark:bg-gray-800 shadow-sm border border-gray-200 dark:border-gray-700 rounded-lg p-6">
<SectionTitle className="flex items-center gap-2 mb-4">
<FileText className="h-5 w-5" />
آمار سریع
</SectionTitle>
<div className="space-y-3">
<div className="flex justify-between items-center">
<span className="text-sm text-gray-600 dark:text-gray-400">تعداد نقشها</span>
<span className="font-medium text-gray-900 dark:text-gray-100">
{user.roles ? user.roles.length : 0}
</span>
</div>
<div className="flex justify-between items-center">
<span className="text-sm text-gray-600 dark:text-gray-400">تعداد دسترسیها</span>
<span className="font-medium text-gray-900 dark:text-gray-100">
{user.permissions ? user.permissions.length : 0}
</span>
</div>
</div>
</div>
</div>
</div>
</PageContainer>
);
};
export default AdminUserDetailPage;

View File

@ -0,0 +1,273 @@
import { useEffect } from 'react';
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 { useAdminUser, useCreateAdminUser, useUpdateAdminUser } from '../core/_hooks';
import { AdminUserFormData } from '../core/_models';
import { usePermissions } from '../../permissions/core/_hooks';
import { useRoles } from '../../roles/core/_hooks';
import { Button } from "@/components/ui/Button";
import { Input } from "@/components/ui/Input";
import { MultiSelectAutocomplete, Option } from "@/components/ui/MultiSelectAutocomplete";
import { ArrowRight } from "lucide-react";
import { FormHeader, PageContainer, Label } from '../../../components/ui/Typography';
const adminUserSchema = yup.object({
first_name: yup.string().required('نام الزامی است').min(2, 'نام باید حداقل 2 کاراکتر باشد'),
last_name: yup.string().required('نام خانوادگی الزامی است').min(2, 'نام خانوادگی باید حداقل 2 کاراکتر باشد'),
username: yup.string().required('نام کاربری الزامی است').min(3, 'نام کاربری باید حداقل 3 کاراکتر باشد'),
password: yup.string().when('isEdit', {
is: false,
then: (schema) => schema.required('رمز عبور الزامی است').min(8, 'رمز عبور باید حداقل 8 کاراکتر باشد'),
otherwise: (schema) => schema.notRequired().test('min-length', 'رمز عبور باید حداقل 8 کاراکتر باشد', function (value) {
return !value || value.length >= 8;
})
}),
status: yup.string().required('وضعیت الزامی است').oneOf(['active', 'deactive'], 'وضعیت نامعتبر است'),
permissions: yup.array().of(yup.number()).default([]),
roles: yup.array().of(yup.number()).default([]),
isEdit: yup.boolean().default(false)
});
const AdminUserFormPage = () => {
const navigate = useNavigate();
const { id } = useParams<{ id: string }>();
const isEdit = !!id;
const { data: user, isLoading: isLoadingUser } = useAdminUser(id || '', isEdit);
const { mutate: createUser, isPending: isCreating } = useCreateAdminUser();
const { mutate: updateUser, isPending: isUpdating } = useUpdateAdminUser();
const { data: permissions, isLoading: isLoadingPermissions } = usePermissions();
const { data: roles, isLoading: isLoadingRoles } = useRoles();
const isLoading = isCreating || isUpdating;
const {
register,
handleSubmit,
formState: { errors, isValid, isDirty },
setValue,
watch
} = useForm<AdminUserFormData>({
resolver: yupResolver(adminUserSchema) as any,
mode: 'onChange',
defaultValues: {
first_name: '',
last_name: '',
username: '',
password: '',
status: 'active' as 'active' | 'deactive',
permissions: [],
roles: [],
isEdit: isEdit
}
});
// Debug form state
const formValues = watch();
console.log('🔍 Current form values:', formValues);
console.log('🔍 Form isValid:', isValid);
console.log('🔍 Form isDirty:', isDirty);
console.log('🔍 Form errors:', errors);
// Populate form when editing
useEffect(() => {
if (isEdit && user) {
setValue('first_name', user.first_name, { shouldValidate: true });
setValue('last_name', user.last_name, { shouldValidate: true });
setValue('username', user.username, { shouldValidate: true });
setValue('status', user.status, { shouldValidate: true });
setValue('permissions', user.permissions?.map((p: any) => p.id) || [], { shouldValidate: true });
setValue('roles', user.roles?.map((r: any) => r.id) || [], { shouldValidate: true });
setValue('isEdit', true, { shouldValidate: true });
}
}, [isEdit, user, setValue]);
const onSubmit = (data: AdminUserFormData) => {
if (isEdit && id) {
updateUser({
id,
userData: {
id: parseInt(id),
first_name: data.first_name,
last_name: data.last_name,
username: data.username,
password: data.password && data.password.trim() ? data.password : undefined,
status: data.status,
permissions: data.permissions,
roles: data.roles
}
}, {
onSuccess: () => {
navigate('/admin-users');
}
});
} else {
console.log('🚀 Creating new admin user...');
createUser({
first_name: data.first_name,
last_name: data.last_name,
username: data.username,
password: data.password || '',
status: data.status,
permissions: data.permissions,
roles: data.roles
}, {
onSuccess: (result) => {
console.log('✅ Admin user created successfully:', result);
console.log('🔄 Navigating to admin users list...');
navigate('/admin-users');
},
onError: (error) => {
console.error('❌ Error in component onError:', error);
}
});
}
};
const handleBack = () => {
navigate('/admin-users');
};
if (isEdit && isLoadingUser) {
return (
<PageContainer>
<div className="space-y-6 animate-pulse">
<div className="h-8 bg-gray-200 dark:bg-gray-700 rounded w-48"></div>
<div className="card p-6 space-y-6">
{[...Array(6)].map((_, i) => (
<div key={i}>
<div className="h-4 bg-gray-200 dark:bg-gray-700 rounded w-32 mb-2"></div>
<div className="h-10 bg-gray-200 dark:bg-gray-700 rounded"></div>
</div>
))}
</div>
</div>
</PageContainer>
);
}
const backButton = (
<Button
variant="secondary"
onClick={handleBack}
className="flex items-center gap-2"
>
<ArrowRight className="h-4 w-4" />
بازگشت
</Button>
);
return (
<PageContainer className="max-w-2xl mx-auto">
<FormHeader
title={isEdit ? 'ویرایش کاربر ادمین' : 'ایجاد کاربر ادمین جدید'}
subtitle={isEdit ? 'ویرایش اطلاعات کاربر ادمین' : 'اطلاعات کاربر ادمین جدید را وارد کنید'}
backButton={backButton}
/>
{/* Form */}
<div className="card p-4 sm:p-6">
<form onSubmit={handleSubmit(onSubmit)} className="space-y-4 sm:space-y-6">
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
<Input
label="نام"
{...register('first_name')}
error={errors.first_name?.message}
placeholder="نام کاربر"
/>
<Input
label="نام خانوادگی"
{...register('last_name')}
error={errors.last_name?.message}
placeholder="نام خانوادگی کاربر"
/>
</div>
<Input
label="نام کاربری"
{...register('username')}
error={errors.username?.message}
placeholder="نام کاربری"
/>
<Input
label={isEdit ? "رمز عبور (اختیاری)" : "رمز عبور"}
type="password"
{...register('password')}
error={errors.password?.message}
placeholder={isEdit ? "رمز عبور جدید (در صورت تمایل به تغییر)" : "رمز عبور"}
/>
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
<MultiSelectAutocomplete
label="دسترسی‌ها"
options={(permissions || []).map((permission): Option => ({
id: permission.id,
title: permission.title,
description: permission.description
}))}
selectedValues={watch('permissions') || []}
onChange={(values) => setValue('permissions', values, { shouldValidate: true })}
placeholder="انتخاب دسترسی‌ها..."
isLoading={isLoadingPermissions}
error={errors.permissions?.message}
/>
<MultiSelectAutocomplete
label="نقش‌ها"
options={(roles || []).map((role): Option => ({
id: role.id,
title: role.title,
description: role.description
}))}
selectedValues={watch('roles') || []}
onChange={(values) => setValue('roles', values, { shouldValidate: true })}
placeholder="انتخاب نقش‌ها..."
isLoading={isLoadingRoles}
error={errors.roles?.message}
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
وضعیت
</label>
<select
{...register('status')}
className="w-full px-3 py-3 text-base border border-gray-300 dark:border-gray-600 rounded-lg focus:outline-none focus:ring-2 focus:ring-primary-500 focus:border-primary-500 dark:bg-gray-700 dark:text-gray-100 transition-all duration-200"
>
<option value="active">فعال</option>
<option value="deactive">غیرفعال</option>
</select>
{errors.status && (
<p className="text-red-500 text-sm mt-1">{errors.status.message}</p>
)}
</div>
<div className="flex justify-end space-x-4 space-x-reverse pt-6 border-t border-gray-200 dark:border-gray-600">
<Button
type="button"
variant="secondary"
onClick={handleBack}
disabled={isLoading}
>
انصراف
</Button>
<Button
type="submit"
loading={isLoading}
disabled={!isValid || isLoading}
>
{isEdit ? 'به‌روزرسانی' : 'ایجاد'}
</Button>
</div>
</form>
</div>
</PageContainer>
);
};
export default AdminUserFormPage;

View File

@ -0,0 +1,243 @@
import React, { useState } from 'react';
import { useNavigate } from 'react-router-dom';
import { useAdminUsers, useDeleteAdminUser } from '../core/_hooks';
import { AdminUserInfo } from '../core/_models';
import { Button } from "@/components/ui/Button";
import { Users, UserPlus, Plus } from "lucide-react";
import { PageContainer, SectionSubtitle } from '../../../components/ui/Typography';
import { TableSkeleton } from '@/components/common/TableSkeleton';
import { PageHeader } from '@/components/layout/PageHeader';
import { EmptyState } from '@/components/common/EmptyState';
import { ActionButtons } from '@/components/common/ActionButtons';
import { DeleteConfirmModal } from '@/components/common/DeleteConfirmModal';
import { formatDate } from '@/utils/formatters';
const AdminUsersListPage = () => {
const navigate = useNavigate();
const [deleteUserId, setDeleteUserId] = useState<string | null>(null);
const [filters, setFilters] = useState({
search: '',
status: ''
});
const { data: users, isLoading, error } = useAdminUsers(filters);
const { mutate: deleteUser, isPending: isDeleting } = useDeleteAdminUser();
const handleCreate = () => {
navigate('/admin-users/create');
};
const handleView = (userId: number) => {
navigate(`/admin-users/${userId}`);
};
const handleEdit = (userId: number) => {
navigate(`/admin-users/${userId}/edit`);
};
const handleDeleteConfirm = () => {
if (deleteUserId) {
deleteUser(deleteUserId, {
onSuccess: () => {
setDeleteUserId(null);
}
});
}
};
const handleSearchChange = (e: React.ChangeEvent<HTMLInputElement>) => {
setFilters(prev => ({ ...prev, search: e.target.value }));
};
const handleStatusChange = (e: React.ChangeEvent<HTMLSelectElement>) => {
setFilters(prev => ({ ...prev, status: e.target.value }));
};
if (error) {
return (
<div className="p-6">
<div className="text-center py-12">
<p className="text-red-600 dark:text-red-400">خطا در بارگذاری کاربران ادمین</p>
</div>
</div>
);
}
return (
<PageContainer>
<PageHeader
title="مدیریت کاربران ادمین"
subtitle="مدیریت کاربران دسترسی به پنل ادمین"
icon={Users}
actions={
<button
onClick={handleCreate}
className="flex items-center justify-center w-12 h-12 bg-primary-600 hover:bg-primary-700 rounded-full transition-colors duration-200 text-white shadow-lg hover:shadow-xl"
title="کاربر ادمین جدید"
>
<Plus className="h-5 w-5" />
</button>
}
/>
{/* Filters */}
<SectionSubtitle>فیلترها</SectionSubtitle>
<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-2 gap-4">
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
جستجو
</label>
<input
type="text"
placeholder="جستجو در نام، نام خانوادگی یا نام کاربری..."
value={filters.search}
onChange={handleSearchChange}
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md focus:outline-none focus:ring-1 focus:ring-primary-500 dark:bg-gray-700 dark:text-gray-100"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
وضعیت
</label>
<select
value={filters.status}
onChange={handleStatusChange}
className="w-full px-3 py-3 text-base border border-gray-300 dark:border-gray-600 rounded-lg focus:outline-none focus:ring-2 focus:ring-primary-500 focus:border-primary-500 dark:bg-gray-700 dark:text-gray-100 transition-all duration-200"
>
<option value="">همه</option>
<option value="active">فعال</option>
<option value="deactive">غیرفعال</option>
</select>
</div>
</div>
</div>
{/* Users Table */}
{isLoading ? (
<TableSkeleton columns={5} rows={5} />
) : (users || []).length === 0 ? (
<div className="bg-white dark:bg-gray-800 shadow-sm border border-gray-200 dark:border-gray-700 rounded-lg">
<EmptyState
icon={Users}
title="هیچ کاربر ادمین یافت نشد"
description={filters.search || filters.status
? "نتیجه‌ای برای جستجوی شما یافت نشد"
: "شما هنوز هیچ کاربر ادمین ایجاد نکرده‌اید"
}
actionLabel={
<>
<UserPlus className="h-4 w-4 ml-2" />
اولین کاربر ادمین را ایجاد کنید
</>
}
onAction={handleCreate}
/>
</div>
) : (
<div className="bg-white dark:bg-gray-800 shadow-sm border border-gray-200 dark:border-gray-700 rounded-lg overflow-hidden">
{/* Desktop Table */}
<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>
</tr>
</thead>
<tbody className="bg-white dark:bg-gray-800 divide-y divide-gray-200 dark:divide-gray-700">
{(users || []).map((user: AdminUserInfo) => (
<tr key={user.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">
{user.first_name} {user.last_name}
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-900 dark:text-gray-100">
{user.username}
</td>
<td className="px-6 py-4 whitespace-nowrap">
<span className={`inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium ${user.status === 'active'
? 'bg-green-100 text-green-800 dark:bg-green-800 dark:text-green-100'
: 'bg-red-100 text-red-800 dark:bg-red-800 dark:text-red-100'
}`}>
{user.status === 'active' ? 'فعال' : 'غیرفعال'}
</span>
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-900 dark:text-gray-100">
{formatDate(user.created_at)}
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm font-medium">
<ActionButtons
onView={() => handleView(user.id)}
onEdit={() => handleEdit(user.id)}
onDelete={() => setDeleteUserId(user.id.toString())}
/>
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
{/* Mobile Cards */}
<div className="md:hidden p-4 space-y-4">
{(users || []).map((user: AdminUserInfo) => (
<div key={user.id} className="border border-gray-200 dark:border-gray-700 rounded-lg p-4">
<div className="flex justify-between items-start mb-2">
<div>
<h3 className="text-sm font-medium text-gray-900 dark:text-gray-100">
{user.first_name} {user.last_name}
</h3>
<p className="text-sm text-gray-600 dark:text-gray-400">
{user.username}
</p>
</div>
<span className={`inline-flex items-center px-2 py-1 rounded-full text-xs font-medium ${user.status === 'active'
? 'bg-green-100 text-green-800 dark:bg-green-800 dark:text-green-100'
: 'bg-red-100 text-red-800 dark:bg-red-800 dark:text-red-100'
}`}>
{user.status === 'active' ? 'فعال' : 'غیرفعال'}
</span>
</div>
<div className="text-xs text-gray-500 dark:text-gray-400 mb-3">
تاریخ ایجاد: {formatDate(user.created_at)}
</div>
<ActionButtons
onView={() => handleView(user.id)}
onEdit={() => handleEdit(user.id)}
onDelete={() => setDeleteUserId(user.id.toString())}
/>
</div>
))}
</div>
</div>
)}
<DeleteConfirmModal
isOpen={!!deleteUserId}
onClose={() => setDeleteUserId(null)}
onConfirm={handleDeleteConfirm}
title="حذف کاربر ادمین"
message="آیا از حذف این کاربر ادمین اطمینان دارید؟ این عمل قابل بازگشت نیست."
isLoading={isDeleting}
/>
</PageContainer>
);
};
export default AdminUsersListPage;

View File

@ -0,0 +1,90 @@
import { QUERY_KEYS } from "@/utils/query-key";
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import {
getAdminUsers,
getAdminUser,
createAdminUser,
updateAdminUser,
deleteAdminUser,
} from "./_requests";
import {
CreateAdminUserRequest,
UpdateAdminUserRequest,
AdminUserFilters,
} from "./_models";
import toast from "react-hot-toast";
export const useAdminUsers = (filters?: AdminUserFilters) => {
return useQuery({
queryKey: [QUERY_KEYS.GET_ADMIN_USERS, filters],
queryFn: () => getAdminUsers(filters),
});
};
export const useAdminUser = (id: string, enabled: boolean = true) => {
return useQuery({
queryKey: [QUERY_KEYS.GET_ADMIN_USER, id],
queryFn: () => getAdminUser(id),
enabled: enabled && !!id,
});
};
export const useCreateAdminUser = () => {
const queryClient = useQueryClient();
return useMutation({
mutationKey: [QUERY_KEYS.CREATE_ADMIN_USER],
mutationFn: (userData: CreateAdminUserRequest) => createAdminUser(userData),
onSuccess: (data) => {
queryClient.invalidateQueries({ queryKey: [QUERY_KEYS.GET_ADMIN_USERS] });
toast.success("کاربر ادمین با موفقیت ایجاد شد");
},
onError: (error: any) => {
console.error("Create admin user error:", error);
toast.error(error?.message || "خطا در ایجاد کاربر ادمین");
},
});
};
export const useUpdateAdminUser = () => {
const queryClient = useQueryClient();
return useMutation({
mutationKey: [QUERY_KEYS.UPDATE_ADMIN_USER],
mutationFn: ({
id,
userData,
}: {
id: string;
userData: UpdateAdminUserRequest;
}) => updateAdminUser(id, userData),
onSuccess: (data, variables) => {
queryClient.invalidateQueries({ queryKey: [QUERY_KEYS.GET_ADMIN_USERS] });
queryClient.invalidateQueries({
queryKey: [QUERY_KEYS.GET_ADMIN_USER, variables.id],
});
toast.success("کاربر ادمین با موفقیت به‌روزرسانی شد");
},
onError: (error: any) => {
console.error("Update admin user error:", error);
toast.error(error?.message || "خطا در به‌روزرسانی کاربر ادمین");
},
});
};
export const useDeleteAdminUser = () => {
const queryClient = useQueryClient();
return useMutation({
mutationKey: [QUERY_KEYS.DELETE_ADMIN_USER],
mutationFn: (id: string) => deleteAdminUser(id),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: [QUERY_KEYS.GET_ADMIN_USERS] });
toast.success("کاربر ادمین با موفقیت حذف شد");
},
onError: (error: any) => {
console.error("Delete admin user error:", error);
toast.error(error?.message || "خطا در حذف کاربر ادمین");
},
});
};

View File

@ -0,0 +1,50 @@
import {
AdminUserInfo,
CreateAdminUserRequest,
UpdateAdminUserRequest,
} from "@/types/auth";
export interface AdminUserFormData {
first_name: string;
last_name: string;
username: string;
password?: string;
status: "active" | "deactive";
permissions: number[];
roles: number[];
isEdit: boolean;
}
export interface AdminUserFilters {
search?: string;
status?: string;
page?: number;
limit?: number;
}
export interface AdminUsersResponse {
users: AdminUserInfo[] | null;
}
export interface AdminUserResponse {
user: AdminUserInfo;
}
export interface CreateAdminUserResponse {
user: AdminUserInfo;
}
export interface UpdateAdminUserResponse {
user: AdminUserInfo;
}
export interface DeleteAdminUserResponse {
message: string;
}
// Export types for easier access
export type {
AdminUserInfo,
CreateAdminUserRequest,
UpdateAdminUserRequest,
} from "@/types/auth";

View File

@ -0,0 +1,154 @@
import {
httpGetRequest,
httpPostRequest,
httpPutRequest,
httpDeleteRequest,
APIUrlGenerator,
} from "@/utils/baseHttpService";
import { API_ROUTES } from "@/constant/routes";
import {
AdminUserInfo,
CreateAdminUserRequest,
UpdateAdminUserRequest,
AdminUsersResponse,
AdminUserResponse,
CreateAdminUserResponse,
UpdateAdminUserResponse,
DeleteAdminUserResponse,
AdminUserFilters,
} from "./_models";
export const getAdminUsers = async (filters?: AdminUserFilters) => {
try {
const queryParams: Record<string, string | number | null> = {};
if (filters?.search) queryParams.search = filters.search;
if (filters?.status) queryParams.status = filters.status;
if (filters?.page) queryParams.page = filters.page;
if (filters?.limit) queryParams.limit = filters.limit;
const url = APIUrlGenerator(API_ROUTES.GET_ADMIN_USERS, queryParams);
console.log("🔍 Admin Users URL:", url);
console.log("🔍 API_ROUTES.GET_ADMIN_USERS:", API_ROUTES.GET_ADMIN_USERS);
const response = await httpGetRequest<AdminUsersResponse>(url);
console.log("Admin Users API Response:", response);
console.log("Admin Users data:", response.data);
// Handle different response structures
if (response.data && (response.data as any).admin_users) {
return Array.isArray((response.data as any).admin_users)
? (response.data as any).admin_users
: [];
}
if (response.data && response.data.users) {
return Array.isArray(response.data.users) ? response.data.users : [];
}
if (response.data && Array.isArray(response.data)) {
return response.data;
}
return [];
} catch (error) {
console.error("Error fetching admin users:", error);
return [];
}
};
export const getAdminUser = async (id: string) => {
try {
const response = await httpGetRequest<AdminUserResponse>(
APIUrlGenerator(API_ROUTES.GET_ADMIN_USER(id))
);
console.log("Get Admin User API Response:", response);
console.log("Get Admin User data:", response.data);
if (response.data && (response.data as any).admin_user) {
return (response.data as any).admin_user;
}
if (response.data && response.data.user) {
return response.data.user;
}
throw new Error("Failed to get admin user");
} catch (error) {
console.error("Error getting admin user:", error);
throw error;
}
};
export const createAdminUser = async (userData: CreateAdminUserRequest) => {
try {
console.log("🚀 Creating admin user with data:", userData);
const response = await httpPostRequest<CreateAdminUserResponse>(
APIUrlGenerator(API_ROUTES.CREATE_ADMIN_USER),
userData
);
console.log("✅ Create Admin User API Response:", response);
console.log("📊 Response data:", response.data);
if (response.data && (response.data as any).admin_user) {
console.log("✅ Returning admin_user from response");
return (response.data as any).admin_user;
}
if (response.data && response.data.user) {
console.log("✅ Returning user from response");
return response.data.user;
}
console.log("⚠️ Response structure unexpected, throwing error");
throw new Error("Failed to create admin user");
} catch (error) {
console.error("❌ Error creating admin user:", error);
throw error;
}
};
export const updateAdminUser = async (
id: string,
userData: UpdateAdminUserRequest
) => {
try {
const response = await httpPutRequest<UpdateAdminUserResponse>(
APIUrlGenerator(API_ROUTES.UPDATE_ADMIN_USER(id)),
userData
);
console.log("Update Admin User API Response:", response);
console.log("Update Admin User data:", response.data);
if (response.data && (response.data as any).admin_user) {
return (response.data as any).admin_user;
}
if (response.data && response.data.user) {
return response.data.user;
}
throw new Error("Failed to update admin user");
} catch (error) {
console.error("Error updating admin user:", error);
throw error;
}
};
export const deleteAdminUser = async (id: string) => {
try {
const response = await httpDeleteRequest<DeleteAdminUserResponse>(
APIUrlGenerator(API_ROUTES.DELETE_ADMIN_USER(id))
);
return response.data;
} catch (error) {
console.error("Error deleting admin user:", error);
throw error;
}
};

View File

@ -0,0 +1,30 @@
import { QUERY_KEYS } from "@/utils/query-key";
import { useMutation } from "@tanstack/react-query";
import { postLogin } from "./_requests";
import { LoginRequest, LoginResponse } from "@/types/auth";
import toast from "react-hot-toast";
export const useLogin = () => {
return useMutation({
mutationKey: [QUERY_KEYS.ADMIN_LOGIN],
mutationFn: (credentials: LoginRequest) => postLogin(credentials),
onSuccess: (response: LoginResponse) => {
localStorage.setItem("admin_token", response.tokens.access_token);
localStorage.setItem(
"admin_refresh_token",
response.tokens.refresh_token
);
localStorage.setItem("admin_user", JSON.stringify(response.admin_user));
localStorage.setItem(
"admin_permissions",
JSON.stringify(response.permissions)
);
toast.success("ورود موفقیت‌آمیز بود");
},
onError: (error: any) => {
console.error("Login error:", error);
toast.error(error?.message || "خطا در ورود");
},
});
};

View File

@ -0,0 +1,18 @@
import { httpPostRequest, APIUrlGenerator } from "@/utils/baseHttpService";
import { API_ROUTES } from "@/constant/routes";
import { LoginRequest, LoginResponse } from "@/types/auth";
export const postLogin = async (credentials: LoginRequest) => {
const response = await httpPostRequest<LoginResponse>(
APIUrlGenerator(API_ROUTES.ADMIN_LOGIN),
credentials
);
return response.data;
};
export const postLogout = () => {
localStorage.removeItem("admin_token");
localStorage.removeItem("admin_refresh_token");
localStorage.removeItem("admin_user");
localStorage.removeItem("admin_permissions");
};

19
src/pages/auth/index.ts Normal file
View File

@ -0,0 +1,19 @@
export const getAuth = async () => {
const token = localStorage.getItem("admin_token");
const userStr = localStorage.getItem("admin_user");
if (token && userStr) {
try {
const user = JSON.parse(userStr);
return { token, user };
} catch (error) {
localStorage.removeItem("admin_token");
localStorage.removeItem("admin_refresh_token");
localStorage.removeItem("admin_user");
localStorage.removeItem("admin_permissions");
return null;
}
}
return null;
};

View File

@ -0,0 +1,203 @@
import React, { useState } from 'react';
import { useNavigate } from 'react-router-dom';
import { useCategories, useDeleteCategory } from '../core/_hooks';
import { Category } from '../core/_models';
import { Button } from "@/components/ui/Button";
import { Plus, FolderOpen, Folder } from "lucide-react";
import { PageContainer } from "../../../components/ui/Typography";
import { PageHeader } from "@/components/layout/PageHeader";
import { FiltersSection } from "@/components/common/FiltersSection";
import { TableSkeleton } from "@/components/common/TableSkeleton";
import { EmptyState } from "@/components/common/EmptyState";
import { DeleteConfirmModal } from "@/components/common/DeleteConfirmModal";
import { ActionButtons } from "@/components/common/ActionButtons";
import { formatDate } from "@/utils/formatters";
const CategoriesListPage = () => {
const navigate = useNavigate();
const [deleteCategoryId, setDeleteCategoryId] = useState<string | null>(null);
const [filters, setFilters] = useState({
search: ''
});
const { data: categories, isLoading, error } = useCategories(filters);
const { mutate: deleteCategory, isPending: isDeleting } = useDeleteCategory();
const handleCreate = () => {
navigate('/categories/create');
};
const handleEdit = (categoryId: number) => {
navigate(`/categories/${categoryId}/edit`);
};
const handleDeleteConfirm = () => {
if (deleteCategoryId) {
deleteCategory(deleteCategoryId, {
onSuccess: () => {
setDeleteCategoryId(null);
}
});
}
};
const handleSearchChange = (e: React.ChangeEvent<HTMLInputElement>) => {
setFilters(prev => ({ ...prev, search: e.target.value }));
};
if (error) {
return (
<div className="p-6">
<div className="text-center py-12">
<p className="text-red-600 dark:text-red-400">خطا در بارگذاری دستهبندیها</p>
</div>
</div>
);
}
const createButton = (
<button
onClick={handleCreate}
className="flex items-center justify-center w-12 h-12 bg-primary-600 hover:bg-primary-700 rounded-full transition-colors duration-200 text-white shadow-lg hover:shadow-xl"
title="دسته‌بندی جدید"
>
<Plus className="h-5 w-5" />
</button>
);
return (
<PageContainer>
<PageHeader
title="مدیریت دسته‌بندی‌ها"
subtitle="مدیریت دسته‌بندی‌های محصولات"
icon={FolderOpen}
actions={createButton}
/>
<FiltersSection isLoading={isLoading} columns={2}>
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
جستجو
</label>
<input
type="text"
placeholder="جستجو در نام دسته‌بندی..."
value={filters.search}
onChange={handleSearchChange}
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md focus:outline-none focus:ring-1 focus:ring-primary-500 dark:bg-gray-700 dark:text-gray-100"
/>
</div>
</FiltersSection>
{isLoading ? (
<TableSkeleton columns={4} rows={5} />
) : (!categories || categories.length === 0) ? (
<div className="bg-white dark:bg-gray-800 shadow-sm border border-gray-200 dark:border-gray-700 rounded-lg overflow-hidden">
<EmptyState
icon={FolderOpen}
title="دسته‌بندی‌ای موجود نیست"
description="برای شروع، اولین دسته‌بندی محصولات خود را ایجاد کنید."
actionLabel={
<>
<Plus className="h-4 w-4" />
ایجاد دستهبندی جدید
</>
}
onAction={handleCreate}
/>
</div>
) : (
<div className="bg-white dark:bg-gray-800 shadow-sm border border-gray-200 dark:border-gray-700 rounded-lg overflow-hidden">
{/* Desktop Table */}
<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>
</tr>
</thead>
<tbody className="bg-white dark:bg-gray-800 divide-y divide-gray-200 dark:divide-gray-700">
{(categories || []).map((category: Category) => (
<tr key={category.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">
<div className="flex items-center gap-2">
<Folder className="h-4 w-4 text-amber-500" />
{category.name}
</div>
</td>
<td className="px-6 py-4 text-sm text-gray-900 dark:text-gray-100">
<div className="max-w-xs truncate">
{category.description || 'بدون توضیحات'}
</div>
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-900 dark:text-gray-100">
{formatDate(category.created_at)}
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm font-medium">
<ActionButtons
onEdit={() => handleEdit(category.id)}
onDelete={() => setDeleteCategoryId(category.id.toString())}
/>
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
{/* Mobile Cards */}
<div className="md:hidden p-4 space-y-4">
{(categories || []).map((category: Category) => (
<div key={category.id} className="border border-gray-200 dark:border-gray-700 rounded-lg p-4">
<div className="flex justify-between items-start mb-3">
<div className="flex-1">
<h3 className="text-sm font-medium text-gray-900 dark:text-gray-100 flex items-center gap-2">
<Folder className="h-4 w-4 text-amber-500" />
{category.name}
</h3>
<p className="text-sm text-gray-600 dark:text-gray-400 mt-1">
{category.description || 'بدون توضیحات'}
</p>
</div>
</div>
<div className="text-xs text-gray-500 dark:text-gray-400 mb-3">
تاریخ ایجاد: {formatDate(category.created_at)}
</div>
<ActionButtons
onEdit={() => handleEdit(category.id)}
onDelete={() => setDeleteCategoryId(category.id.toString())}
showLabels={true}
size="sm"
/>
</div>
))}
</div>
</div>
)}
<DeleteConfirmModal
isOpen={!!deleteCategoryId}
onClose={() => setDeleteCategoryId(null)}
onConfirm={handleDeleteConfirm}
title="حذف دسته‌بندی"
message="آیا از حذف این دسته‌بندی اطمینان دارید؟ این عمل قابل بازگشت نیست و ممکن است بر محصولاتی که در این دسته‌بندی قرار دارند تأثیر بگذارد."
isLoading={isDeleting}
/>
</PageContainer>
);
};
export default CategoriesListPage;

View File

@ -0,0 +1,219 @@
import { useState, useEffect } from 'react';
import { useNavigate, useParams } from 'react-router-dom';
import { ArrowRight, FolderOpen } from 'lucide-react';
import { Button } from '../../../components/ui/Button';
import { Input } from '../../../components/ui/Input';
import { FileUploader } from '../../../components/ui/FileUploader';
import { useFileUpload, useFileDelete } from '../../../hooks/useFileUpload';
import { useToast } from '../../../contexts/ToastContext';
import { useCategory, useCreateCategory, useUpdateCategory } from '../core/_hooks';
import { FormHeader, PageContainer, Label } from '../../../components/ui/Typography';
const CategoryFormPage = () => {
const navigate = useNavigate();
const { id } = useParams();
const { success: showToast } = useToast();
const isEdit = Boolean(id);
const [formData, setFormData] = useState({
name: '',
description: '',
parent_id: null as number | null,
file_id: undefined as number | undefined,
});
const [uploadedImage, setUploadedImage] = useState<{ id: string, url: string } | null>(null);
const [isUploading, setIsUploading] = useState(false);
const { data: category, isLoading: isLoadingCategory } = useCategory(
id || '0',
isEdit
);
const createMutation = useCreateCategory();
const updateMutation = useUpdateCategory();
const { mutateAsync: uploadFile } = useFileUpload();
const { mutate: deleteFile } = useFileDelete();
useEffect(() => {
if (category && isEdit) {
const fileId = (category as any).file?.id ?? category.file_id;
const fileUrl = (category as any).file?.url || '';
setFormData({
name: category.name || '',
description: category.description || '',
parent_id: (category as any).parent_id || null,
file_id: fileId || undefined,
});
if (fileId && fileUrl) {
setUploadedImage({ id: String(fileId), url: fileUrl });
} else if (fileId) {
setUploadedImage({ id: String(fileId), url: '' });
} else {
setUploadedImage(null);
}
}
}, [category, isEdit]);
const handleChange = (field: string, value: any) => {
setFormData(prev => ({
...prev,
[field]: value
}));
};
const handleFileUpload = async (file: File) => {
try {
const result = await uploadFile(file);
const fileId = parseInt(result.id);
setUploadedImage({
id: result.id,
url: result.url
});
setFormData(prev => ({
...prev,
file_id: fileId
}));
return result;
} catch (error) {
console.error('Upload error:', error);
throw error;
}
};
const handleFileRemove = (fileId: string) => {
setUploadedImage(null);
setFormData(prev => ({
...prev,
file_id: undefined
}));
deleteFile(fileId);
};
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
try {
if (isEdit) {
await updateMutation.mutateAsync({
id: parseInt(id!),
...formData
});
} else {
await createMutation.mutateAsync(formData);
}
} catch (error) {
console.error('Error saving category:', error);
}
};
const handleBack = () => {
navigate('/categories');
};
if (isEdit && isLoadingCategory) {
return (
<PageContainer>
<div className="space-y-6 animate-pulse">
<div className="h-8 bg-gray-200 dark:bg-gray-700 rounded w-48"></div>
<div className="card p-6 space-y-6">
{[...Array(4)].map((_, i) => (
<div key={i}>
<div className="h-4 bg-gray-200 dark:bg-gray-700 rounded w-32 mb-2"></div>
<div className="h-10 bg-gray-200 dark:bg-gray-700 rounded"></div>
</div>
))}
</div>
</div>
</PageContainer>
);
}
const backButton = (
<Button
variant="secondary"
onClick={handleBack}
className="flex items-center gap-2"
>
<ArrowRight className="h-4 w-4" />
بازگشت
</Button>
);
return (
<PageContainer className="max-w-2xl mx-auto">
<FormHeader
title={isEdit ? 'ویرایش دسته‌بندی' : 'ایجاد دسته‌بندی جدید'}
subtitle={isEdit ? 'ویرایش اطلاعات دسته‌بندی' : 'اطلاعات دسته‌بندی جدید را وارد کنید'}
backButton={backButton}
/>
<div className="card p-4 sm:p-6">
<form onSubmit={handleSubmit} className="space-y-4 sm:space-y-6">
<div>
<Label htmlFor="name">نام دستهبندی</Label>
<Input
id="name"
type="text"
value={formData.name}
onChange={(e) => handleChange('name', e.target.value)}
placeholder="نام دسته‌بندی را وارد کنید"
required
/>
</div>
<div>
<Label htmlFor="description">توضیحات</Label>
<textarea
id="description"
value={formData.description}
onChange={(e) => handleChange('description', e.target.value)}
placeholder="توضیحات دسته‌بندی"
rows={4}
className="input resize-none"
/>
</div>
<div>
<FileUploader
onUpload={handleFileUpload}
onRemove={handleFileRemove}
acceptedTypes={['image/*']}
maxFileSize={5 * 1024 * 1024}
maxFiles={1}
mode="single"
label="تصویر دسته‌بندی"
description="تصویر دسته‌بندی را انتخاب کنید (حداکثر 5MB)"
onUploadStart={() => setIsUploading(true)}
onUploadComplete={() => setIsUploading(false)}
initialFiles={uploadedImage ? [{ id: uploadedImage.id, url: uploadedImage.url }] : []}
/>
</div>
<div className="flex flex-col space-y-3 sm:flex-row sm:justify-end sm:space-y-0 sm:space-x-3 sm:space-x-reverse pt-4 border-t border-gray-200 dark:border-gray-700">
<Button
type="button"
variant="secondary"
onClick={handleBack}
className="w-full sm:w-auto"
>
انصراف
</Button>
<Button
type="submit"
loading={createMutation.isPending || updateMutation.isPending}
disabled={isUploading}
className="w-full sm:w-auto"
>
{isEdit ? 'ویرایش' : 'ایجاد'}
</Button>
</div>
</form>
</div>
</PageContainer>
);
};
export default CategoryFormPage;

View File

@ -0,0 +1,98 @@
import { QUERY_KEYS } from "@/utils/query-key";
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import { useNavigate } from "react-router-dom";
import {
getCategories,
getCategory,
createCategory,
updateCategory,
deleteCategory,
} from "./_requests";
import {
CreateCategoryRequest,
UpdateCategoryRequest,
CategoryFilters,
} from "./_models";
import toast from "react-hot-toast";
export const useCategories = (filters?: CategoryFilters) => {
return useQuery({
queryKey: [QUERY_KEYS.GET_CATEGORIES, filters],
queryFn: () => getCategories(filters),
});
};
export const useSearchCategories = (filters: CategoryFilters) => {
return useQuery({
queryKey: [QUERY_KEYS.SEARCH_CATEGORIES, filters],
queryFn: () => getCategories(filters),
enabled: Object.keys(filters).length > 0,
staleTime: 2 * 60 * 1000, // 2 minutes for search results
});
};
export const useCategory = (id: string, enabled: boolean = true) => {
return useQuery({
queryKey: [QUERY_KEYS.GET_CATEGORY, id],
queryFn: () => getCategory(id),
enabled: enabled && !!id,
});
};
export const useCreateCategory = () => {
const queryClient = useQueryClient();
const navigate = useNavigate();
return useMutation({
mutationKey: [QUERY_KEYS.CREATE_CATEGORY],
mutationFn: (data: CreateCategoryRequest) => createCategory(data),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: [QUERY_KEYS.GET_CATEGORIES] });
toast.success("دسته‌بندی با موفقیت ایجاد شد");
navigate("/categories");
},
onError: (error: any) => {
console.error("Create category error:", error);
toast.error(error?.message || "خطا در ایجاد دسته‌بندی");
},
});
};
export const useUpdateCategory = () => {
const queryClient = useQueryClient();
const navigate = useNavigate();
return useMutation({
mutationKey: [QUERY_KEYS.UPDATE_CATEGORY],
mutationFn: (data: UpdateCategoryRequest) => updateCategory(data),
onSuccess: (_, variables) => {
queryClient.invalidateQueries({ queryKey: [QUERY_KEYS.GET_CATEGORIES] });
queryClient.invalidateQueries({
queryKey: [QUERY_KEYS.GET_CATEGORY, variables.id.toString()],
});
toast.success("دسته‌بندی با موفقیت ویرایش شد");
navigate("/categories");
},
onError: (error: any) => {
console.error("Update category error:", error);
toast.error(error?.message || "خطا در ویرایش دسته‌بندی");
},
});
};
export const useDeleteCategory = () => {
const queryClient = useQueryClient();
return useMutation({
mutationKey: [QUERY_KEYS.DELETE_CATEGORY],
mutationFn: (id: string) => deleteCategory(id),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: [QUERY_KEYS.GET_CATEGORIES] });
toast.success("دسته‌بندی با موفقیت حذف شد");
},
onError: (error: any) => {
console.error("Delete category error:", error);
toast.error(error?.message || "خطا در حذف دسته‌بندی");
},
});
};

View File

@ -0,0 +1,54 @@
export interface Category {
id: number;
name: string;
description?: string;
parent_id?: number;
file_id?: number;
created_at: string;
updated_at: string;
}
export interface CategoryFormData {
name: string;
description: string;
file_id?: number;
}
export interface CategoryFilters {
search?: string;
page?: number;
limit?: number;
}
export interface CreateCategoryRequest {
name: string;
description?: string;
file_id?: number;
}
export interface UpdateCategoryRequest {
id: number;
name: string;
description?: string;
file_id?: number;
}
export interface CategoriesResponse {
categories: Category[] | null;
}
export interface CategoryResponse {
category: Category;
}
export interface CreateCategoryResponse {
category: Category;
}
export interface UpdateCategoryResponse {
category: Category;
}
export interface DeleteCategoryResponse {
message: string;
}

View File

@ -0,0 +1,84 @@
import {
httpGetRequest,
httpPostRequest,
httpPutRequest,
httpDeleteRequest,
APIUrlGenerator,
} from "@/utils/baseHttpService";
import { API_ROUTES } from "@/constant/routes";
import {
Category,
CreateCategoryRequest,
UpdateCategoryRequest,
CategoriesResponse,
CategoryResponse,
CreateCategoryResponse,
UpdateCategoryResponse,
DeleteCategoryResponse,
CategoryFilters,
} from "./_models";
export const getCategories = async (filters?: CategoryFilters) => {
try {
const queryParams: Record<string, string | number | null> = {};
if (filters?.search) queryParams.search = filters.search;
if (filters?.page) queryParams.page = filters.page;
if (filters?.limit) queryParams.limit = filters.limit;
const response = await httpGetRequest<CategoriesResponse>(
APIUrlGenerator(API_ROUTES.GET_CATEGORIES, queryParams, undefined, false)
);
console.log("Categories API Response:", response);
if (
response.data &&
response.data.categories &&
Array.isArray(response.data.categories)
) {
return response.data.categories;
}
console.warn("Categories is null or not an array:", response.data);
return [];
} catch (error) {
console.error("Error fetching categories:", error);
return [];
}
};
export const getCategory = async (id: string) => {
const response = await httpGetRequest<CategoryResponse>(
APIUrlGenerator(API_ROUTES.GET_CATEGORY(id), undefined, undefined, false)
);
return response.data.category;
};
export const createCategory = async (data: CreateCategoryRequest) => {
const response = await httpPostRequest<CreateCategoryResponse>(
APIUrlGenerator(API_ROUTES.CREATE_CATEGORY, undefined, undefined, false),
data
);
return response.data.category;
};
export const updateCategory = async (data: UpdateCategoryRequest) => {
const response = await httpPutRequest<UpdateCategoryResponse>(
APIUrlGenerator(
API_ROUTES.UPDATE_CATEGORY(data.id.toString()),
undefined,
undefined,
false
),
data
);
return response.data.category;
};
export const deleteCategory = async (id: string) => {
const response = await httpDeleteRequest<DeleteCategoryResponse>(
APIUrlGenerator(API_ROUTES.DELETE_CATEGORY(id), undefined, undefined, false)
);
return response.data;
};

View File

@ -0,0 +1,173 @@
import React, { useMemo, useState } from 'react';
import { MessageSquare, Trash2 } from 'lucide-react';
import { PageContainer } from '@/components/ui/Typography';
import { PageHeader } from '@/components/layout/PageHeader';
import { Table } from '@/components/ui/Table';
import { TableColumn } from '@/types';
import { Pagination } from '@/components/ui/Pagination';
import { DeleteConfirmModal } from '@/components/common/DeleteConfirmModal';
import { englishToPersian } from '@/utils/numberUtils';
import { formatDateTime } from '@/utils/formatters';
import { useContactUsMessages, useDeleteContactUsMessage } from '../core/_hooks';
import { ContactUsFilters, ContactUsMessage } from '../core/_models';
const ContactUsListPage: React.FC = () => {
const [filters, setFilters] = useState<ContactUsFilters>({
limit: 20,
offset: 0,
});
const [deleteTarget, setDeleteTarget] = useState<ContactUsMessage | null>(
null
);
const { data, isLoading, error } = useContactUsMessages(filters);
const deleteMessageMutation = useDeleteContactUsMessage();
const messages = data?.messages || [];
const total = data?.total ?? messages.length;
const limit = filters.limit || 20;
const currentPage = Math.floor((filters.offset || 0) / limit) + 1;
const totalPages = total > 0 ? Math.ceil(total / limit) : 1;
const handlePageChange = (page: number) => {
setFilters((prev) => ({
...prev,
offset: (page - 1) * prev.limit,
}));
};
const handleDeleteConfirm = () => {
if (!deleteTarget) return;
deleteMessageMutation.mutate(deleteTarget.ID, {
onSuccess: () => setDeleteTarget(null),
});
};
const columns: TableColumn[] = useMemo(
() => [
{
key: 'id',
label: 'شناسه',
align: 'center',
render: (value: number) => englishToPersian(value),
},
{
key: 'name',
label: 'نام',
align: 'right',
render: (value: string) => value || '-',
},
{
key: 'phone',
label: 'شماره تماس',
align: 'left',
render: (value: string) => {
const display = value ? englishToPersian(value) : '-';
return <span dir="ltr">{display}</span>;
},
},
{
key: 'message',
label: 'پیام',
align: 'right',
render: (value: string) => {
if (!value) return '-';
return value.length > 120 ? `${value.slice(0, 120)}...` : value;
},
},
{
key: 'created_at',
label: 'تاریخ',
align: 'right',
render: (value: string) => formatDateTime(value),
},
{
key: 'actions',
label: 'عملیات',
align: 'center',
render: (_val, row: any) => (
<div className="flex items-center justify-center">
<button
onClick={() => setDeleteTarget(row.raw)}
className="text-red-600 hover:text-red-900 dark:text-red-400 dark:hover:text-red-300 p-1"
title="حذف پیام"
>
<Trash2 className="h-4 w-4" />
</button>
</div>
),
},
],
[]
);
const tableData = messages.map((message) => ({
id: message.ID,
name: message.Name || '-',
phone: message.PhoneNumber || '-',
message: message.Message || '-',
created_at: message.CreatedAt,
raw: message,
}));
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">
<PageHeader
title="پیام‌های تماس با ما"
subtitle="لیست پیام‌های ارسال‌شده توسط کاربران"
icon={MessageSquare}
/>
<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} />
) : messages.length === 0 ? (
<div className="text-center py-12">
<MessageSquare 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">
هنوز پیامی برای نمایش وجود ندارد
</p>
</div>
) : (
<Table columns={columns} data={tableData} />
)}
</div>
{messages.length > 0 && totalPages > 1 && (
<Pagination
currentPage={currentPage}
totalPages={totalPages}
onPageChange={handlePageChange}
itemsPerPage={limit}
totalItems={total}
/>
)}
</div>
<DeleteConfirmModal
isOpen={!!deleteTarget}
onClose={() => setDeleteTarget(null)}
onConfirm={handleDeleteConfirm}
title="حذف پیام تماس با ما"
message="آیا از حذف این پیام اطمینان دارید؟ این عمل قابل بازگشت نیست."
isLoading={deleteMessageMutation.isPending}
/>
</PageContainer>
);
};
export default ContactUsListPage;

View File

@ -0,0 +1,28 @@
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import toast from "react-hot-toast";
import { QUERY_KEYS } from "@/utils/query-key";
import { getContactUsMessages, deleteContactUsMessage } from "./_requests";
import { ContactUsFilters } from "./_models";
export const useContactUsMessages = (filters?: ContactUsFilters) => {
return useQuery({
queryKey: [QUERY_KEYS.GET_CONTACT_US_MESSAGES, filters],
queryFn: () => getContactUsMessages(filters),
});
};
export const useDeleteContactUsMessage = () => {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (id: string | number) => deleteContactUsMessage(id),
onSuccess: () => {
queryClient.invalidateQueries({
queryKey: [QUERY_KEYS.GET_CONTACT_US_MESSAGES],
});
toast.success("پیام تماس با ما حذف شد");
},
onError: (error: any) => {
toast.error(error?.message || "خطا در حذف پیام تماس با ما");
},
});
};

Some files were not shown because too many files have changed in this diff Show More