Compare commits
No commits in common. "main" and "master" have entirely different histories.
|
|
@ -12,7 +12,6 @@
|
|||
|
||||
# production
|
||||
/build
|
||||
/dist
|
||||
|
||||
# misc
|
||||
.DS_Store
|
||||
|
|
|
|||
|
|
@ -1,10 +1,10 @@
|
|||
FROM node:18-alpine AS builder
|
||||
FROM node:18-alpine as builder
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
COPY package*.json ./
|
||||
|
||||
RUN npm ci --legacy-peer-deps
|
||||
RUN npm ci
|
||||
|
||||
COPY . .
|
||||
|
||||
|
|
|
|||
|
|
@ -1,27 +0,0 @@
|
|||
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.
|
|
@ -1,106 +0,0 @@
|
|||
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");
|
||||
});
|
||||
});
|
||||
|
|
@ -1,211 +0,0 @@
|
|||
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");
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -1,151 +0,0 @@
|
|||
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");
|
||||
});
|
||||
});
|
||||
|
|
@ -1,51 +0,0 @@
|
|||
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", "داشبورد");
|
||||
});
|
||||
});
|
||||
|
|
@ -1,472 +0,0 @@
|
|||
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
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -1,373 +0,0 @@
|
|||
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");
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -1,251 +0,0 @@
|
|||
/// <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);
|
||||
});
|
||||
});
|
||||
|
|
@ -1,331 +0,0 @@
|
|||
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");
|
||||
});
|
||||
});
|
||||
|
|
@ -1,168 +0,0 @@
|
|||
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");
|
||||
});
|
||||
});
|
||||
|
|
@ -1,146 +0,0 @@
|
|||
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");
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -1,144 +0,0 @@
|
|||
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");
|
||||
});
|
||||
});
|
||||
|
|
@ -1,179 +0,0 @@
|
|||
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");
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -1,230 +0,0 @@
|
|||
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");
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -1,58 +0,0 @@
|
|||
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");
|
||||
});
|
||||
});
|
||||
|
|
@ -1,349 +0,0 @@
|
|||
/// <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);
|
||||
});
|
||||
});
|
||||
|
|
@ -1,180 +0,0 @@
|
|||
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");
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -1,131 +0,0 @@
|
|||
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");
|
||||
});
|
||||
});
|
||||
|
|
@ -1,2 +0,0 @@
|
|||
# This would be a test image file
|
||||
# For demo purposes, this represents an image placeholder
|
||||
|
|
@ -1 +0,0 @@
|
|||
# This is an invalid file format for testing file upload validation
|
||||
|
|
@ -1,36 +0,0 @@
|
|||
// ***********************************************
|
||||
// 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";
|
||||
|
|
@ -1,38 +0,0 @@
|
|||
// ***********************************************************
|
||||
// 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 />)
|
||||
|
|
@ -1,310 +0,0 @@
|
|||
// 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,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
|
@ -1,31 +0,0 @@
|
|||
// ***********************************************************
|
||||
// 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>;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,19 +0,0 @@
|
|||
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.
|
|
@ -1,9 +0,0 @@
|
|||
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
|
||||
File diff suppressed because it is too large
Load Diff
27
package.json
27
package.json
|
|
@ -7,55 +7,38 @@
|
|||
"dev": "vite",
|
||||
"build": "tsc && vite build",
|
||||
"lint": "eslint . --ext ts,tsx --report-unused-disable-directives --max-warnings 0",
|
||||
"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"
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"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": "^19.2.3",
|
||||
"react-apexcharts": "^1.9.0",
|
||||
"react-date-object": "2.1.9",
|
||||
"react-dom": "^19.2.3",
|
||||
"react": "^18.2.0",
|
||||
"react-dom": "^18.2.0",
|
||||
"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": "^19.2.0",
|
||||
"@types/react-dom": "^19.2.0",
|
||||
"@types/react": "^18.2.15",
|
||||
"@types/react-dom": "^18.2.7",
|
||||
"@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"
|
||||
}
|
||||
|
|
|
|||
185
src/App.tsx
185
src/App.tsx
|
|
@ -1,102 +1,23 @@
|
|||
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';
|
||||
|
||||
// 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 />
|
||||
);
|
||||
}
|
||||
|
||||
const ProtectedRoute = ({ children }: { children: any }) => {
|
||||
const { user } = useAuth();
|
||||
return user ? children : <Navigate to="/login" replace />;
|
||||
};
|
||||
|
||||
|
|
@ -111,94 +32,16 @@ const AppRoutes = () => {
|
|||
}>
|
||||
<Route index element={<Dashboard />} />
|
||||
<Route path="users" element={<Users />} />
|
||||
<Route path="products" element={<ProductsListPage />} />
|
||||
<Route path="products" element={<Products />} />
|
||||
<Route path="orders" element={<Orders />} />
|
||||
<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>
|
||||
);
|
||||
};
|
||||
|
||||
const App = () => {
|
||||
function App() {
|
||||
return (
|
||||
<ErrorBoundary>
|
||||
<QueryClientProvider client={queryClient}>
|
||||
|
|
@ -206,9 +49,7 @@ const App = () => {
|
|||
<ToastProvider>
|
||||
<AuthProvider>
|
||||
<Router>
|
||||
<Suspense fallback={null}>
|
||||
<AppRoutes />
|
||||
</Suspense>
|
||||
</Router>
|
||||
</AuthProvider>
|
||||
</ToastProvider>
|
||||
|
|
@ -217,6 +58,6 @@ const App = () => {
|
|||
</QueryClientProvider>
|
||||
</ErrorBoundary>
|
||||
);
|
||||
};
|
||||
}
|
||||
|
||||
export default App;
|
||||
|
|
@ -1,92 +0,0 @@
|
|||
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>
|
||||
);
|
||||
};
|
||||
|
|
@ -1,82 +0,0 @@
|
|||
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>
|
||||
);
|
||||
};
|
||||
|
|
@ -1,79 +0,0 @@
|
|||
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>
|
||||
);
|
||||
};
|
||||
|
|
@ -1,71 +1,44 @@
|
|||
import { BarChart as RechartsBarChart, Bar, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer } from 'recharts';
|
||||
import { CardTitle } from '../ui/Typography';
|
||||
import { englishToPersian, formatWithThousands } from '@/utils/numberUtils';
|
||||
|
||||
const formatNumber = (value: number | string) => {
|
||||
const formatted = formatWithThousands(value);
|
||||
return englishToPersian(formatted);
|
||||
};
|
||||
|
||||
import { ChartData } from '../../types';
|
||||
|
||||
interface BarChartProps {
|
||||
data: any[];
|
||||
data: ChartData[];
|
||||
title?: string;
|
||||
color?: string;
|
||||
}
|
||||
|
||||
export const BarChart = ({ data, title, color = '#3b82f6' }: BarChartProps) => {
|
||||
return (
|
||||
<div className="card p-3 sm:p-4 lg:p-6">
|
||||
<div className="card p-6">
|
||||
{title && (
|
||||
<CardTitle className="mb-3 sm:mb-4">
|
||||
<h3 className="text-lg font-semibold text-gray-900 dark:text-gray-100 mb-4">
|
||||
{title}
|
||||
</CardTitle>
|
||||
</h3>
|
||||
)}
|
||||
<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" />
|
||||
<ResponsiveContainer width="100%" height={300}>
|
||||
<RechartsBarChart data={data}>
|
||||
<CartesianGrid strokeDasharray="3 3" className="stroke-gray-300 dark:stroke-gray-600" />
|
||||
<XAxis
|
||||
dataKey="name"
|
||||
className="text-gray-600 dark:text-gray-400"
|
||||
tick={{ fontSize: 11, fontFamily: 'inherit' }}
|
||||
tickFormatter={(value) => englishToPersian(value)}
|
||||
interval="preserveStartEnd"
|
||||
height={40}
|
||||
tick={{ fontSize: 12 }}
|
||||
/>
|
||||
<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}
|
||||
tick={{ fontSize: 12 }}
|
||||
/>
|
||||
<Tooltip
|
||||
contentStyle={{
|
||||
backgroundColor: 'var(--toast-bg)',
|
||||
color: 'var(--toast-color)',
|
||||
backgroundColor: 'var(--tooltip-bg)',
|
||||
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)}
|
||||
labelStyle={{ color: 'var(--tooltip-text)' }}
|
||||
/>
|
||||
<Bar dataKey="value" fill="url(#barFill)" radius={[8, 8, 0, 0]} barSize={28} />
|
||||
<Bar dataKey="value" fill={color} radius={[4, 4, 0, 0]} />
|
||||
</RechartsBarChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
|
@ -1,72 +1,44 @@
|
|||
import { LineChart as RechartsLineChart, Line, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer } from 'recharts';
|
||||
import { CardTitle } from '../ui/Typography';
|
||||
import { englishToPersian, formatWithThousands } from '@/utils/numberUtils';
|
||||
|
||||
const formatNumber = (value: number | string) => {
|
||||
const formatted = formatWithThousands(value);
|
||||
return englishToPersian(formatted);
|
||||
};
|
||||
|
||||
import { ChartData } from '../../types';
|
||||
|
||||
interface LineChartProps {
|
||||
data: any[];
|
||||
data: ChartData[];
|
||||
title?: string;
|
||||
color?: string;
|
||||
}
|
||||
|
||||
export const LineChart = ({ data, title, color = '#10b981' }: LineChartProps) => {
|
||||
return (
|
||||
<div className="card p-3 sm:p-4 lg:p-6">
|
||||
<div className="card p-6">
|
||||
{title && (
|
||||
<CardTitle className="mb-3 sm:mb-4">
|
||||
<h3 className="text-lg font-semibold text-gray-900 dark:text-gray-100 mb-4">
|
||||
{title}
|
||||
</CardTitle>
|
||||
</h3>
|
||||
)}
|
||||
<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" />
|
||||
<ResponsiveContainer width="100%" height={300}>
|
||||
<RechartsLineChart data={data}>
|
||||
<CartesianGrid strokeDasharray="3 3" className="stroke-gray-300 dark:stroke-gray-600" />
|
||||
<XAxis
|
||||
dataKey="name"
|
||||
className="text-gray-600 dark:text-gray-400"
|
||||
tick={{ fontSize: 11, fontFamily: 'inherit' }}
|
||||
tickFormatter={(value) => englishToPersian(value)}
|
||||
interval="preserveStartEnd"
|
||||
height={40}
|
||||
tick={{ fontSize: 12 }}
|
||||
/>
|
||||
<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}
|
||||
tick={{ fontSize: 12 }}
|
||||
/>
|
||||
<Tooltip
|
||||
contentStyle={{
|
||||
backgroundColor: 'var(--toast-bg)',
|
||||
color: 'var(--toast-color)',
|
||||
backgroundColor: 'var(--tooltip-bg)',
|
||||
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)}
|
||||
/>
|
||||
<Line
|
||||
type="monotone"
|
||||
dataKey="value"
|
||||
stroke={color}
|
||||
strokeWidth={3}
|
||||
dot={false}
|
||||
activeDot={{ r: 5 }}
|
||||
labelStyle={{ color: 'var(--tooltip-text)' }}
|
||||
/>
|
||||
<Line type="monotone" dataKey="value" stroke={color} strokeWidth={3} dot={{ r: 6 }} />
|
||||
</RechartsLineChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
|
@ -1,9 +1,8 @@
|
|||
import { PieChart as RechartsPieChart, Pie, Cell, Tooltip, ResponsiveContainer, Legend } from 'recharts';
|
||||
import { CardTitle } from '../ui/Typography';
|
||||
import { englishToPersian, formatWithThousands } from '@/utils/numberUtils';
|
||||
import { PieChart as RechartsPieChart, Pie, Cell, ResponsiveContainer, Tooltip } from 'recharts';
|
||||
import { ChartData } from '../../types';
|
||||
|
||||
interface PieChartProps {
|
||||
data: any[];
|
||||
data: ChartData[];
|
||||
title?: string;
|
||||
colors?: string[];
|
||||
}
|
||||
|
|
@ -11,81 +10,40 @@ 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="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">
|
||||
<div className="card p-6">
|
||||
{title && (
|
||||
<CardTitle className="mb-3 sm:mb-4 text-center">
|
||||
<h3 className="text-lg font-semibold text-gray-900 dark:text-gray-100 mb-4">
|
||||
{title}
|
||||
</CardTitle>
|
||||
</h3>
|
||||
)}
|
||||
<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}>
|
||||
<ResponsiveContainer width="100%" height={300}>
|
||||
<RechartsPieChart>
|
||||
<Pie
|
||||
data={data}
|
||||
cx="50%"
|
||||
cy="50%"
|
||||
labelLine={false}
|
||||
label={false}
|
||||
outerRadius="75%"
|
||||
innerRadius="35%"
|
||||
label={({ name, percent }) => `${name} ${(percent * 100).toFixed(0)}%`}
|
||||
outerRadius={80}
|
||||
fill="#8884d8"
|
||||
dataKey="value"
|
||||
stroke="#fff"
|
||||
strokeWidth={3}
|
||||
>
|
||||
{data.map((_, index) => (
|
||||
{data.map((entry, index) => (
|
||||
<Cell key={`cell-${index}`} fill={colors[index % colors.length]} />
|
||||
))}
|
||||
</Pie>
|
||||
<Tooltip
|
||||
contentStyle={{
|
||||
backgroundColor: 'rgba(255, 255, 255, 0.95)',
|
||||
color: '#1f2937',
|
||||
border: '1px solid #e5e7eb',
|
||||
backgroundColor: 'var(--tooltip-bg)',
|
||||
border: 'none',
|
||||
borderRadius: '8px',
|
||||
boxShadow: '0 4px 6px -1px rgba(0, 0, 0, 0.1)',
|
||||
fontSize: '14px',
|
||||
fontWeight: '500',
|
||||
fontFamily: 'inherit',
|
||||
}}
|
||||
formatter={(value: any, name: any) => [`${englishToPersian(Math.round(value))}%`, name]}
|
||||
labelStyle={{ color: 'var(--tooltip-text)' }}
|
||||
/>
|
||||
</RechartsPieChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
|
@ -1,91 +0,0 @@
|
|||
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>
|
||||
);
|
||||
};
|
||||
|
||||
|
|
@ -1,61 +0,0 @@
|
|||
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>
|
||||
);
|
||||
};
|
||||
|
||||
|
|
@ -1,46 +0,0 @@
|
|||
|
||||
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>
|
||||
);
|
||||
};
|
||||
|
||||
|
|
@ -19,7 +19,7 @@ export class ErrorBoundary extends Component<Props, State> {
|
|||
this.state = { hasError: false };
|
||||
}
|
||||
|
||||
static getDerivedStateFromError(_: Error): State {
|
||||
static getDerivedStateFromError(error: Error): State {
|
||||
return { hasError: true };
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,42 +0,0 @@
|
|||
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>
|
||||
);
|
||||
};
|
||||
|
||||
|
|
@ -1,126 +0,0 @@
|
|||
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>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
|
|
@ -1,80 +0,0 @@
|
|||
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>
|
||||
);
|
||||
};
|
||||
|
||||
|
|
@ -1,5 +1,4 @@
|
|||
import { TrendingUp, TrendingDown } from 'lucide-react';
|
||||
import { StatValue, StatLabel } from '../ui/Typography';
|
||||
|
||||
interface StatsCardProps {
|
||||
title: string;
|
||||
|
|
@ -28,31 +27,31 @@ export const StatsCard = ({
|
|||
const isNegative = change && change < 0;
|
||||
|
||||
return (
|
||||
<div className="card p-4 sm:p-5 lg:p-6 animate-fade-in">
|
||||
<div className="card p-6 animate-fade-in">
|
||||
<div className="flex items-center">
|
||||
<div className="flex-shrink-0">
|
||||
<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 className={`p-3 rounded-lg ${colorClasses[color as keyof typeof colorClasses] || colorClasses.blue}`}>
|
||||
<Icon className="h-6 w-6 text-white" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="mr-3 sm:mr-5 w-0 flex-1 min-w-0">
|
||||
<div className="mr-5 w-0 flex-1">
|
||||
<dl>
|
||||
<StatLabel className="truncate">
|
||||
<dt className="text-sm font-medium text-gray-500 dark:text-gray-400 truncate">
|
||||
{title}
|
||||
</StatLabel>
|
||||
</dt>
|
||||
<dd className="flex items-baseline">
|
||||
<StatValue className="truncate">
|
||||
<div className="text-2xl font-semibold text-gray-900 dark:text-gray-100">
|
||||
{typeof value === 'number' ? value.toLocaleString() : value}
|
||||
</StatValue>
|
||||
</div>
|
||||
{change !== undefined && (
|
||||
<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'
|
||||
<div className={`mr-2 flex items-baseline text-sm font-semibold ${isPositive ? 'text-green-600' : isNegative ? 'text-red-600' : 'text-gray-500'
|
||||
}`}>
|
||||
{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" />}
|
||||
{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" />}
|
||||
<span className="sr-only">
|
||||
{isPositive ? 'افزایش' : 'کاهش'}
|
||||
</span>
|
||||
<span className="truncate">{Math.abs(change)}%</span>
|
||||
{Math.abs(change)}%
|
||||
</div>
|
||||
)}
|
||||
</dd>
|
||||
|
|
|
|||
|
|
@ -1,43 +0,0 @@
|
|||
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>
|
||||
);
|
||||
};
|
||||
|
||||
|
|
@ -1,26 +0,0 @@
|
|||
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>
|
||||
);
|
||||
};
|
||||
|
||||
|
|
@ -1,82 +1,126 @@
|
|||
import { useForm } from 'react-hook-form';
|
||||
import { yupResolver } from '@hookform/resolvers/yup';
|
||||
import * as yup from 'yup';
|
||||
|
||||
import { Button } from '../ui/Button';
|
||||
import { User, Phone, Mail, UserCircle } from 'lucide-react';
|
||||
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(),
|
||||
});
|
||||
import { Button } from '../ui/Button';
|
||||
import { userSchema, UserFormData } from '../../utils/validationSchemas';
|
||||
|
||||
interface UserFormProps {
|
||||
initialData?: Partial<UserFormData>;
|
||||
onSubmit: (data: UserFormData) => void;
|
||||
defaultValues?: Partial<UserFormData>;
|
||||
initialData?: any;
|
||||
onCancel?: () => void;
|
||||
onCancel: () => void;
|
||||
loading?: boolean;
|
||||
isEdit?: boolean;
|
||||
isLoading?: boolean;
|
||||
}
|
||||
|
||||
export const UserForm = ({ onSubmit, defaultValues, initialData, onCancel, loading, isEdit, isLoading }: UserFormProps) => {
|
||||
export const UserForm = ({
|
||||
initialData,
|
||||
onSubmit,
|
||||
onCancel,
|
||||
loading = false,
|
||||
isEdit = false
|
||||
}: UserFormProps) => {
|
||||
const {
|
||||
register,
|
||||
handleSubmit,
|
||||
formState: { errors, isValid }
|
||||
} = useForm({
|
||||
formState: { errors, isValid },
|
||||
} = useForm<UserFormData>({
|
||||
resolver: yupResolver(userSchema),
|
||||
defaultValues: defaultValues || initialData,
|
||||
mode: 'onChange'
|
||||
}) as any;
|
||||
mode: 'onChange',
|
||||
defaultValues: initialData,
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="card p-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 as any)} className="space-y-4">
|
||||
<form onSubmit={handleSubmit(onSubmit)} className="space-y-6">
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
<Input
|
||||
label="نام"
|
||||
{...register('name')}
|
||||
label="نام و نام خانوادگی"
|
||||
placeholder="علی احمدی"
|
||||
icon={User}
|
||||
error={errors.name?.message}
|
||||
placeholder="نام کاربر"
|
||||
{...register('name')}
|
||||
/>
|
||||
|
||||
<Input
|
||||
label="ایمیل"
|
||||
type="email"
|
||||
{...register('email')}
|
||||
placeholder="ali@example.com"
|
||||
icon={Mail}
|
||||
error={errors.email?.message}
|
||||
placeholder="example@email.com"
|
||||
{...register('email')}
|
||||
/>
|
||||
|
||||
<Input
|
||||
label="تلفن"
|
||||
label="شماره تلفن"
|
||||
type="tel"
|
||||
{...register('phone')}
|
||||
placeholder="09123456789"
|
||||
icon={Phone}
|
||||
error={errors.phone?.message}
|
||||
placeholder="09xxxxxxxxx"
|
||||
{...register('phone')}
|
||||
/>
|
||||
|
||||
<div className="pt-4">
|
||||
<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>
|
||||
<Button
|
||||
type="submit"
|
||||
disabled={!isValid || isLoading}
|
||||
className="w-full"
|
||||
loading={loading}
|
||||
disabled={!isValid}
|
||||
>
|
||||
{isLoading ? 'در حال ذخیره...' : 'ذخیره'}
|
||||
{isEdit ? 'ویرایش' : 'افزودن'}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
|
|
|
|||
|
|
@ -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 { SectionTitle } from '../ui/Typography';
|
||||
import { Button } from '../ui/Button';
|
||||
|
||||
interface HeaderProps {
|
||||
onMenuClick: () => void;
|
||||
|
|
@ -14,19 +14,21 @@ export const Header = ({ onMenuClick }: HeaderProps) => {
|
|||
const [showUserMenu, setShowUserMenu] = useState(false);
|
||||
|
||||
return (
|
||||
<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">
|
||||
<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">
|
||||
<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>
|
||||
<SectionTitle>خوش آمدید</SectionTitle>
|
||||
<h1 className="mr-4 text-lg font-semibold text-gray-900 dark:text-gray-100">
|
||||
خوش آمدید
|
||||
</h1>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center space-x-2 space-x-reverse">
|
||||
<div className="flex items-center space-x-4">
|
||||
<button
|
||||
onClick={toggleTheme}
|
||||
className="p-2 rounded-lg hover:bg-gray-100 dark:hover:bg-gray-700 transition-colors"
|
||||
|
|
@ -46,15 +48,15 @@ export const Header = ({ onMenuClick }: HeaderProps) => {
|
|||
<div className="relative">
|
||||
<button
|
||||
onClick={() => setShowUserMenu(!showUserMenu)}
|
||||
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"
|
||||
className="flex items-center space-x-2 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?.first_name?.charAt(0) || 'A'}
|
||||
{user?.name?.charAt(0) || 'A'}
|
||||
</span>
|
||||
</div>
|
||||
<span className="text-sm font-medium text-gray-700 dark:text-gray-300 hidden sm:block">
|
||||
{user?.first_name} {user?.last_name}
|
||||
<span className="text-sm font-medium text-gray-700 dark:text-gray-300 hidden md:block">
|
||||
{user?.name || 'کاربر'}
|
||||
</span>
|
||||
</button>
|
||||
|
||||
|
|
@ -63,10 +65,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?.first_name} {user?.last_name}
|
||||
{user?.name}
|
||||
</p>
|
||||
<p className="text-xs text-gray-500 dark:text-gray-400">
|
||||
{user?.username}
|
||||
{user?.email}
|
||||
</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">
|
||||
|
|
|
|||
|
|
@ -1,46 +1,23 @@
|
|||
import { Suspense, useState } from 'react';
|
||||
import { 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 overflow-hidden">
|
||||
<div className="flex h-screen bg-gray-50 dark:bg-gray-900">
|
||||
<Sidebar
|
||||
isOpen={sidebarOpen}
|
||||
onClose={() => setSidebarOpen(false)}
|
||||
/>
|
||||
|
||||
<div className="flex-1 flex flex-col min-w-0 overflow-hidden">
|
||||
<div className="flex-1 flex flex-col 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>
|
||||
|
|
|
|||
|
|
@ -1,40 +0,0 @@
|
|||
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>
|
||||
);
|
||||
};
|
||||
|
||||
|
|
@ -1,186 +1,62 @@
|
|||
import React from 'react';
|
||||
import { NavLink, useLocation } from 'react-router-dom';
|
||||
import { useState } from 'react';
|
||||
import { NavLink } from 'react-router-dom';
|
||||
import {
|
||||
Home,
|
||||
Settings,
|
||||
Shield,
|
||||
UserCog,
|
||||
Key,
|
||||
LogOut,
|
||||
ChevronDown,
|
||||
ChevronLeft,
|
||||
Package,
|
||||
FolderOpen,
|
||||
Sliders,
|
||||
BadgePercent,
|
||||
ShoppingCart,
|
||||
LayoutDashboard,
|
||||
Users,
|
||||
Truck,
|
||||
X,
|
||||
MessageSquare,
|
||||
CreditCard,
|
||||
Wallet,
|
||||
BarChart3,
|
||||
ShoppingBag,
|
||||
ShoppingCart,
|
||||
FileText,
|
||||
TrendingUp
|
||||
Bell,
|
||||
Menu,
|
||||
X,
|
||||
ChevronDown
|
||||
} from 'lucide-react';
|
||||
import { useAuth } from '../../contexts/AuthContext';
|
||||
import { PermissionWrapper } from '../common/PermissionWrapper';
|
||||
import { SectionTitle, SmallText } from '../ui/Typography';
|
||||
|
||||
interface MenuItem {
|
||||
title: string;
|
||||
icon: any;
|
||||
path?: string;
|
||||
permission?: number;
|
||||
children?: MenuItem[];
|
||||
exact?: boolean;
|
||||
}
|
||||
import { MenuItem } from '../../types';
|
||||
|
||||
const menuItems: MenuItem[] = [
|
||||
{
|
||||
title: 'داشبورد',
|
||||
icon: Home,
|
||||
id: 'dashboard',
|
||||
label: 'داشبورد',
|
||||
icon: LayoutDashboard,
|
||||
path: '/',
|
||||
},
|
||||
{
|
||||
title: 'سفارشات',
|
||||
id: 'users',
|
||||
label: 'کاربران',
|
||||
icon: Users,
|
||||
path: '/users',
|
||||
permission: 10,
|
||||
},
|
||||
{
|
||||
id: 'products',
|
||||
label: 'محصولات',
|
||||
icon: ShoppingBag,
|
||||
path: '/products',
|
||||
permission: 15,
|
||||
},
|
||||
{
|
||||
id: 'orders',
|
||||
label: 'سفارشات',
|
||||
icon: ShoppingCart,
|
||||
path: '/orders',
|
||||
permission: 20,
|
||||
},
|
||||
{
|
||||
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: 'پیامهای تماس با ما',
|
||||
id: 'reports',
|
||||
label: 'گزارشها',
|
||||
icon: FileText,
|
||||
path: '/contact-us',
|
||||
path: '/reports',
|
||||
permission: 25,
|
||||
},
|
||||
{
|
||||
title: 'مدیریت محصولات',
|
||||
icon: Package,
|
||||
children: [
|
||||
{
|
||||
title: 'محصولات',
|
||||
icon: Package,
|
||||
path: '/products',
|
||||
id: 'notifications',
|
||||
label: 'اعلانات',
|
||||
icon: Bell,
|
||||
path: '/notifications',
|
||||
permission: 30,
|
||||
},
|
||||
{
|
||||
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 {
|
||||
|
|
@ -189,129 +65,98 @@ interface SidebarProps {
|
|||
}
|
||||
|
||||
export const Sidebar = ({ isOpen, onClose }: SidebarProps) => {
|
||||
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 { user } = useAuth();
|
||||
const [expandedItems, setExpandedItems] = useState<string[]>([]);
|
||||
|
||||
// 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 toggleExpanded = (itemId: string) => {
|
||||
setExpandedItems(prev =>
|
||||
prev.includes(itemId)
|
||||
? prev.filter(id => id !== itemId)
|
||||
: [...prev, itemId]
|
||||
);
|
||||
};
|
||||
|
||||
const renderMenuItem = (item: MenuItem, depth = 0) => {
|
||||
const renderMenuItem = (item: MenuItem) => {
|
||||
const hasChildren = item.children && item.children.length > 0;
|
||||
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 isExpanded = expandedItems.includes(item.id);
|
||||
|
||||
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={item.path!}
|
||||
end={item.exact}
|
||||
onClick={() => {
|
||||
// Close mobile menu when clicking a link
|
||||
if (window.innerWidth < 1024) {
|
||||
onClose();
|
||||
}
|
||||
}}
|
||||
to={child.path}
|
||||
className={({ isActive }) =>
|
||||
`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'
|
||||
`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' : ''
|
||||
}`
|
||||
}
|
||||
style={{ paddingLeft: `${paddingLeft + 16}px` }}
|
||||
onClick={onClose}
|
||||
>
|
||||
<item.icon className="ml-3 h-5 w-5" />
|
||||
{item.title}
|
||||
{child.label}
|
||||
</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 (item.permission) {
|
||||
if (!hasChildren) {
|
||||
return (
|
||||
<PermissionWrapper key={item.title} permission={item.permission}>
|
||||
{menuContent}
|
||||
</PermissionWrapper>
|
||||
<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>
|
||||
);
|
||||
}
|
||||
|
||||
return <div key={item.title}>{menuContent}</div>;
|
||||
return <div>{menuContent}</div>;
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Mobile overlay */}
|
||||
{isOpen && (
|
||||
<div
|
||||
className="fixed inset-0 bg-black bg-opacity-50 z-40 lg:hidden"
|
||||
|
|
@ -319,62 +164,58 @@ export const Sidebar = ({ isOpen, onClose }: SidebarProps) => {
|
|||
/>
|
||||
)}
|
||||
|
||||
{/* Sidebar */}
|
||||
<div className={`
|
||||
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
|
||||
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
|
||||
`}>
|
||||
{/* 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>
|
||||
پنل مدیریت
|
||||
</SectionTitle>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="p-2 rounded-xl hover:bg-gray-100 dark:hover:bg-gray-700 transition-colors"
|
||||
>
|
||||
<X className="h-5 w-5 text-gray-600 dark:text-gray-400" />
|
||||
</button>
|
||||
<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>
|
||||
|
||||
{/* 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>
|
||||
<span className="mr-3 text-xl font-bold text-gray-900 dark:text-gray-100">
|
||||
پنل مدیریت
|
||||
</SectionTitle>
|
||||
</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"
|
||||
onClick={onClose}
|
||||
className="p-1 rounded-lg hover:bg-gray-100 dark:hover:bg-gray-700 lg:hidden"
|
||||
>
|
||||
<LogOut className="h-5 w-5" />
|
||||
<X className="h-5 w-5" />
|
||||
</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>
|
||||
</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>
|
||||
))}
|
||||
</nav>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
|
|
|
|||
|
|
@ -1,13 +1,12 @@
|
|||
import { clsx } from 'clsx';
|
||||
import { MouseEvent, ButtonHTMLAttributes } from 'react';
|
||||
|
||||
interface ButtonProps extends Omit<ButtonHTMLAttributes<HTMLButtonElement>, 'type' | 'onClick'> {
|
||||
interface ButtonProps {
|
||||
children: any;
|
||||
variant?: 'primary' | 'secondary' | 'danger' | 'success';
|
||||
size?: 'sm' | 'md' | 'lg';
|
||||
disabled?: boolean;
|
||||
loading?: boolean;
|
||||
onClick?: (e?: MouseEvent<HTMLButtonElement>) => void;
|
||||
onClick?: () => void;
|
||||
type?: 'button' | 'submit' | 'reset';
|
||||
className?: string;
|
||||
}
|
||||
|
|
@ -21,9 +20,8 @@ export const Button = ({
|
|||
onClick,
|
||||
type = 'button',
|
||||
className = '',
|
||||
...rest
|
||||
}: ButtonProps) => {
|
||||
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 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 variantClasses = {
|
||||
primary: 'bg-primary-600 hover:bg-primary-700 text-white focus:ring-primary-500',
|
||||
|
|
@ -54,7 +52,6 @@ export const Button = ({
|
|||
disabledClasses,
|
||||
className
|
||||
)}
|
||||
{...rest}
|
||||
>
|
||||
{loading && (
|
||||
<svg
|
||||
|
|
|
|||
|
|
@ -1,351 +0,0 @@
|
|||
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>
|
||||
);
|
||||
};
|
||||
|
|
@ -1,88 +1,54 @@
|
|||
import React from 'react';
|
||||
import { forwardRef } from 'react';
|
||||
import { clsx } from 'clsx';
|
||||
import { Label } from './Typography';
|
||||
import { persianToEnglish, formatWithThousands } from '../../utils/numberUtils';
|
||||
|
||||
interface InputProps extends Omit<React.InputHTMLAttributes<HTMLInputElement>, 'size'> {
|
||||
interface InputProps {
|
||||
label?: string;
|
||||
error?: string;
|
||||
helperText?: string;
|
||||
inputSize?: 'sm' | 'md' | 'lg';
|
||||
icon?: React.ComponentType<{ className?: string }>;
|
||||
numeric?: boolean;
|
||||
thousandSeparator?: boolean;
|
||||
type?: string;
|
||||
placeholder?: string;
|
||||
className?: string;
|
||||
icon?: any;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
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
|
||||
};
|
||||
|
||||
export const Input = forwardRef<HTMLInputElement, InputProps>(
|
||||
({ label, error, type = 'text', placeholder, className, icon: Icon, disabled, ...props }, ref) => {
|
||||
return (
|
||||
<div className="space-y-1">
|
||||
{label && <Label htmlFor={id}>{label}</Label>}
|
||||
<input {...inputProps} />
|
||||
{helperText && !error && (
|
||||
<p className="text-xs text-gray-500 dark:text-gray-400">{helperText}</p>
|
||||
{label && (
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||
{label}
|
||||
</label>
|
||||
)}
|
||||
<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-xs text-red-600 dark:text-red-400" role="alert">{error}</p>
|
||||
<p className="text-sm text-red-600 dark:text-red-400">
|
||||
{error}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
Input.displayName = 'Input';
|
||||
|
|
@ -1,91 +0,0 @@
|
|||
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;
|
||||
|
||||
|
||||
|
|
@ -1,15 +1,12 @@
|
|||
import React, { useEffect } from 'react';
|
||||
import { useEffect } from 'react';
|
||||
import { X } from 'lucide-react';
|
||||
import { SectionSubtitle } from './Typography';
|
||||
|
||||
interface ModalProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
title: string;
|
||||
children: React.ReactNode;
|
||||
title?: string;
|
||||
children: any;
|
||||
size?: 'sm' | 'md' | 'lg' | 'xl';
|
||||
showCloseButton?: boolean;
|
||||
actions?: React.ReactNode;
|
||||
}
|
||||
|
||||
export const Modal = ({
|
||||
|
|
@ -17,9 +14,7 @@ export const Modal = ({
|
|||
onClose,
|
||||
title,
|
||||
children,
|
||||
size = 'md',
|
||||
showCloseButton = true,
|
||||
actions
|
||||
size = 'md'
|
||||
}: ModalProps) => {
|
||||
useEffect(() => {
|
||||
const handleEscape = (e: KeyboardEvent) => {
|
||||
|
|
@ -45,7 +40,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 (
|
||||
|
|
@ -57,31 +52,26 @@ export const Modal = ({
|
|||
/>
|
||||
|
||||
<div className={`
|
||||
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
|
||||
relative bg-white dark:bg-gray-800 rounded-lg shadow-xl w-full
|
||||
${sizeClasses[size]} transform transition-all
|
||||
`}>
|
||||
<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 && (
|
||||
{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>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="text-gray-400 hover:text-gray-600 dark:hover:text-gray-300 transition-colors"
|
||||
className="p-1 rounded-lg 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-500 dark:text-gray-400" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="p-4 sm:p-6">
|
||||
<div className="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>
|
||||
|
|
|
|||
|
|
@ -1,219 +0,0 @@
|
|||
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>
|
||||
);
|
||||
};
|
||||
|
|
@ -1,207 +0,0 @@
|
|||
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>
|
||||
);
|
||||
};
|
||||
|
|
@ -1,215 +0,0 @@
|
|||
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>
|
||||
);
|
||||
};
|
||||
|
||||
|
|
@ -75,7 +75,7 @@ export const Table = ({ columns, data, loading = false }: TableProps) => {
|
|||
|
||||
return (
|
||||
<>
|
||||
<div className="hidden md:block card overflow-x-auto">
|
||||
<div className="hidden md:block card overflow-hidden">
|
||||
<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,22 +83,15 @@ export const Table = ({ columns, data, loading = false }: TableProps) => {
|
|||
<th
|
||||
key={column.key}
|
||||
className={clsx(
|
||||
'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',
|
||||
'px-6 py-3 text-right text-xs font-medium text-gray-500 dark:text-gray-300 uppercase tracking-wider',
|
||||
column.sortable && 'cursor-pointer hover:bg-gray-100 dark:hover:bg-gray-600'
|
||||
)}
|
||||
onClick={() => column.sortable && handleSort(column.key)}
|
||||
>
|
||||
<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>
|
||||
<div className="flex items-center justify-end space-x-1">
|
||||
<span>{column.label}</span>
|
||||
{column.sortable && (
|
||||
<div className="flex flex-col ml-1">
|
||||
<div className="flex flex-col">
|
||||
<ChevronUp
|
||||
className={clsx(
|
||||
'h-3 w-3',
|
||||
|
|
@ -126,12 +119,7 @@ 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={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'
|
||||
)}>
|
||||
<td key={column.key} className="px-6 py-4 whitespace-nowrap text-sm text-gray-900 dark:text-gray-100 text-right">
|
||||
{column.render ? column.render(row[column.key], row) : row[column.key]}
|
||||
</td>
|
||||
))}
|
||||
|
|
|
|||
|
|
@ -1,111 +0,0 @@
|
|||
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>
|
||||
);
|
||||
};
|
||||
|
|
@ -1,41 +0,0 @@
|
|||
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>
|
||||
);
|
||||
};
|
||||
|
||||
|
|
@ -1,122 +0,0 @@
|
|||
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>
|
||||
);
|
||||
|
|
@ -1,696 +0,0 @@
|
|||
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>
|
||||
);
|
||||
};
|
||||
|
|
@ -1 +0,0 @@
|
|||
export const pageSize = 10;
|
||||
|
|
@ -1,168 +0,0 @@
|
|||
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}`,
|
||||
};
|
||||
|
|
@ -1,58 +1,41 @@
|
|||
import React, { createContext, useContext, useReducer, useEffect } from 'react';
|
||||
import { AuthState, AdminUser, Permission } from '../types/auth';
|
||||
import toast from 'react-hot-toast';
|
||||
import { createContext, useContext, useReducer, useEffect } from 'react';
|
||||
import { AuthState, User } from '../types';
|
||||
|
||||
interface AuthContextType {
|
||||
isAuthenticated: boolean;
|
||||
isLoading: boolean;
|
||||
user: AdminUser | null;
|
||||
permissions: Permission[];
|
||||
allPermissions: Permission[];
|
||||
token: string | null;
|
||||
refreshToken: string | null;
|
||||
interface AuthContextType extends AuthState {
|
||||
login: (email: string, password: string) => Promise<boolean>;
|
||||
logout: () => void;
|
||||
restoreSession: () => void;
|
||||
hasPermission: (permissionId: number) => boolean;
|
||||
hasPermissionByTitle: (title: string) => boolean;
|
||||
hasPermission: (permission: number) => boolean;
|
||||
}
|
||||
|
||||
const AuthContext = createContext<AuthContextType | undefined>(undefined);
|
||||
|
||||
type AuthAction =
|
||||
| { type: 'LOGIN'; payload: { user: AdminUser; permissions: Permission[]; allPermissions: Permission[]; token: string; refreshToken: string } }
|
||||
| { type: 'LOGIN_SUCCESS'; payload: { user: User; token: string } }
|
||||
| { type: 'LOGOUT' }
|
||||
| { type: 'RESTORE_SESSION'; payload: { user: AdminUser; permissions: Permission[]; allPermissions: Permission[]; token: string; refreshToken: string } }
|
||||
| { type: 'SET_LOADING'; payload: boolean };
|
||||
| { type: 'RESTORE_SESSION'; payload: { user: User; token: string } };
|
||||
|
||||
const authReducer = (state: AuthState, action: AuthAction): AuthState => {
|
||||
switch (action.type) {
|
||||
case 'LOGIN':
|
||||
case 'RESTORE_SESSION':
|
||||
case 'LOGIN_SUCCESS':
|
||||
return {
|
||||
...state,
|
||||
isAuthenticated: true,
|
||||
isLoading: false,
|
||||
user: action.payload.user,
|
||||
permissions: action.payload.permissions,
|
||||
allPermissions: action.payload.allPermissions,
|
||||
permissions: action.payload.user.permissions,
|
||||
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 'SET_LOADING':
|
||||
case 'RESTORE_SESSION':
|
||||
return {
|
||||
...state,
|
||||
isLoading: action.payload,
|
||||
isAuthenticated: true,
|
||||
user: action.payload.user,
|
||||
permissions: action.payload.user.permissions,
|
||||
token: action.payload.token,
|
||||
};
|
||||
default:
|
||||
return state;
|
||||
|
|
@ -61,90 +44,79 @@ 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);
|
||||
|
||||
const restoreSession = () => {
|
||||
dispatch({ type: 'SET_LOADING', payload: true });
|
||||
|
||||
useEffect(() => {
|
||||
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) {
|
||||
if (token && userStr) {
|
||||
try {
|
||||
const user = JSON.parse(userStr);
|
||||
const permissions = JSON.parse(permissionsStr);
|
||||
|
||||
dispatch({
|
||||
type: 'RESTORE_SESSION',
|
||||
payload: {
|
||||
user,
|
||||
permissions,
|
||||
allPermissions: permissions,
|
||||
token,
|
||||
refreshToken: refreshToken || ''
|
||||
}
|
||||
});
|
||||
dispatch({ type: 'RESTORE_SESSION', payload: { user, token } });
|
||||
} catch (error) {
|
||||
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 });
|
||||
}
|
||||
}, []);
|
||||
|
||||
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(),
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
restoreSession();
|
||||
}, []);
|
||||
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));
|
||||
|
||||
dispatch({
|
||||
type: 'LOGIN_SUCCESS',
|
||||
payload: { user: mockUser, token: mockToken }
|
||||
});
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
} catch (error) {
|
||||
console.error('Login error:', error);
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
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 = (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);
|
||||
const hasPermission = (permission: number): boolean => {
|
||||
return state.permissions.includes(permission);
|
||||
};
|
||||
|
||||
return (
|
||||
<AuthContext.Provider value={{
|
||||
...state,
|
||||
login,
|
||||
logout,
|
||||
restoreSession,
|
||||
hasPermission,
|
||||
hasPermissionByTitle,
|
||||
}}>
|
||||
{children}
|
||||
</AuthContext.Provider>
|
||||
|
|
|
|||
|
|
@ -12,14 +12,13 @@ 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 || 'light';
|
||||
const initialTheme = savedTheme || (prefersDark ? 'dark' : 'light');
|
||||
setMode(initialTheme);
|
||||
|
||||
if (initialTheme === 'dark') {
|
||||
document.documentElement.classList.add('dark');
|
||||
} else {
|
||||
document.documentElement.classList.remove('dark');
|
||||
}
|
||||
}, []);
|
||||
|
||||
|
|
|
|||
|
|
@ -1,75 +0,0 @@
|
|||
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 || "خطا در حذف فایل");
|
||||
},
|
||||
});
|
||||
};
|
||||
|
|
@ -78,7 +78,7 @@ export const useCreateUser = () => {
|
|||
return useMutation({
|
||||
mutationFn: (userData: CreateUserRequest) =>
|
||||
userService.createUser(userData),
|
||||
onSuccess: () => {
|
||||
onSuccess: (response) => {
|
||||
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: (_, variables) => {
|
||||
onSuccess: (response, variables) => {
|
||||
queryClient.invalidateQueries({ queryKey: ["users"] });
|
||||
queryClient.invalidateQueries({ queryKey: ["user", variables.id] });
|
||||
toast.success("کاربر با موفقیت ویرایش شد");
|
||||
|
|
|
|||
|
|
@ -30,36 +30,16 @@
|
|||
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-xl shadow-md border border-gray-200 dark:border-gray-700 transition-shadow duration-200 hover:shadow-lg;
|
||||
@apply bg-white dark:bg-gray-800 rounded-lg shadow-sm border border-gray-200 dark:border-gray-700;
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
|
|
@ -73,76 +53,4 @@
|
|||
.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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,17 +1,27 @@
|
|||
import { QueryClient } from "@tanstack/react-query";
|
||||
import toast from "react-hot-toast";
|
||||
|
||||
export const queryClient = new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: {
|
||||
gcTime: 0,
|
||||
staleTime: 0,
|
||||
refetchOnMount: true,
|
||||
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,
|
||||
refetchOnReconnect: true,
|
||||
refetchOnWindowFocus: true,
|
||||
retry: 1,
|
||||
},
|
||||
mutations: {
|
||||
retry: 1,
|
||||
onError: (error: any) => {
|
||||
const message =
|
||||
error?.response?.data?.message ||
|
||||
error?.message ||
|
||||
"خطایی رخ داده است";
|
||||
toast.error(message);
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,135 +1,159 @@
|
|||
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 { 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 { PieChart } from '../components/charts/PieChart';
|
||||
import { Table } from '../components/ui/Table';
|
||||
import { Button } from '../components/ui/Button';
|
||||
import { PageContainer, PageTitle, CardTitle } from '../components/ui/Typography';
|
||||
import { PermissionWrapper } from '../components/common/PermissionWrapper';
|
||||
import { ChartData, TableColumn } from '../types';
|
||||
|
||||
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[] = [
|
||||
const statsData = [
|
||||
{
|
||||
key: 'order_number',
|
||||
label: 'شماره سفارش',
|
||||
render: (value: string) => `#${value}`,
|
||||
title: 'کل کاربران',
|
||||
value: 1247,
|
||||
change: 12,
|
||||
icon: Users,
|
||||
color: 'blue',
|
||||
},
|
||||
{
|
||||
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: '۲۴,۵۶۷,۰۰۰',
|
||||
change: 8.5,
|
||||
icon: DollarSign,
|
||||
color: 'green',
|
||||
},
|
||||
{
|
||||
key: 'final_total',
|
||||
label: 'مبلغ',
|
||||
render: (_value, row: any) => formatCurrency(row.final_total || row.total_amount || 0),
|
||||
title: 'کل سفارشات',
|
||||
value: 356,
|
||||
change: -2.3,
|
||||
icon: ShoppingBag,
|
||||
color: 'yellow',
|
||||
},
|
||||
{
|
||||
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: any) => <StatusBadge status={value} type="order" />,
|
||||
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>
|
||||
)
|
||||
},
|
||||
{ key: 'createdAt', label: 'تاریخ عضویت' },
|
||||
{
|
||||
key: 'created_at',
|
||||
label: 'تاریخ',
|
||||
render: (value: string) => formatDate(value),
|
||||
},
|
||||
];
|
||||
|
||||
const ordersTableData = (recentOrders?.orders || []).map((item) => item.order ?? item);
|
||||
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>
|
||||
)
|
||||
}
|
||||
];
|
||||
|
||||
export const Dashboard = () => {
|
||||
return (
|
||||
<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 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>
|
||||
</div>
|
||||
|
||||
{/* 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}
|
||||
<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}
|
||||
title="فروش ماهانه"
|
||||
color="#3b82f6"
|
||||
/>
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<ApexAreaChartCard
|
||||
data={registrationGrowthData}
|
||||
title="روند رشد ثبتنام کاربران"
|
||||
<LineChart
|
||||
data={chartData}
|
||||
title="روند رشد"
|
||||
color="#10b981"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 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 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}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<div>
|
||||
<PieChart
|
||||
data={salesByCategoryData}
|
||||
title="توزیع فروش بر اساس دستهبندی"
|
||||
data={pieData}
|
||||
title="دستگاههای کاربری"
|
||||
colors={['#3b82f6', '#10b981', '#f59e0b']}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</PageContainer>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
|
@ -1,22 +1,19 @@
|
|||
import { useState } from 'react';
|
||||
import { Navigate, useNavigate } from 'react-router-dom';
|
||||
import { Eye, EyeOff, Lock, User } from 'lucide-react';
|
||||
import { Navigate } from 'react-router-dom';
|
||||
import { Eye, EyeOff, Lock, Mail } 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, isLoading, restoreSession } = useAuth();
|
||||
const navigate = useNavigate();
|
||||
const { isAuthenticated, login } = useAuth();
|
||||
const [showPassword, setShowPassword] = useState(false);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
|
||||
const { mutate: login, isPending: isLoggingIn } = useLogin();
|
||||
|
||||
const {
|
||||
register,
|
||||
handleSubmit,
|
||||
|
|
@ -26,33 +23,24 @@ 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('');
|
||||
|
||||
login(data, {
|
||||
onSuccess: () => {
|
||||
restoreSession();
|
||||
navigate('/');
|
||||
},
|
||||
onError: () => {
|
||||
setError('نام کاربری یا رمز عبور اشتباه است');
|
||||
try {
|
||||
const success = await login(data.email, data.password);
|
||||
if (!success) {
|
||||
setError('ایمیل یا رمز عبور اشتباه است');
|
||||
}
|
||||
} catch (error) {
|
||||
setError('خطایی رخ داده است. لطفا دوباره تلاش کنید');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
|
|
@ -73,12 +61,12 @@ export const Login = () => {
|
|||
<form className="mt-8 space-y-6" onSubmit={handleSubmit(onSubmit)}>
|
||||
<div className="space-y-4">
|
||||
<Input
|
||||
label="نام کاربری"
|
||||
type="text"
|
||||
placeholder="نام کاربری خود را وارد کنید"
|
||||
icon={User}
|
||||
error={errors.username?.message}
|
||||
{...register('username')}
|
||||
label="ایمیل"
|
||||
type="email"
|
||||
placeholder="admin@test.com"
|
||||
icon={Mail}
|
||||
error={errors.email?.message}
|
||||
{...register('email')}
|
||||
/>
|
||||
|
||||
<div className="space-y-1">
|
||||
|
|
@ -91,7 +79,7 @@ export const Login = () => {
|
|||
</div>
|
||||
<input
|
||||
type={showPassword ? 'text' : 'password'}
|
||||
placeholder="رمز عبور خود را وارد کنید"
|
||||
placeholder="admin123"
|
||||
className={`input pr-10 pl-10 ${errors.password ? 'border-red-500 dark:border-red-500 focus:ring-red-500' : ''
|
||||
}`}
|
||||
{...register('password')}
|
||||
|
|
@ -122,9 +110,15 @@ 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={isLoggingIn}
|
||||
loading={loading}
|
||||
disabled={!isValid}
|
||||
className="w-full"
|
||||
>
|
||||
|
|
|
|||
|
|
@ -1,11 +1,7 @@
|
|||
import { useState } from 'react';
|
||||
import { Plus, Search, Filter, Bell, BellOff, Clock, Eye } from 'lucide-react';
|
||||
import { Table } from '../components/ui/Table';
|
||||
import { Bell, Check, X, Plus, Search, Filter, AlertCircle, Info, CheckCircle, XCircle } from 'lucide-react';
|
||||
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 = [
|
||||
{
|
||||
|
|
@ -100,13 +96,13 @@ export const Notifications = () => {
|
|||
const getNotificationIcon = (type: string) => {
|
||||
switch (type) {
|
||||
case 'error':
|
||||
return <BellOff className="h-5 w-5 text-red-600" />;
|
||||
return <XCircle className="h-5 w-5 text-red-600" />;
|
||||
case 'warning':
|
||||
return <Bell className="h-5 w-5 text-yellow-600" />;
|
||||
return <AlertCircle className="h-5 w-5 text-yellow-600" />;
|
||||
case 'success':
|
||||
return <Bell className="h-5 w-5 text-green-600" />;
|
||||
return <CheckCircle className="h-5 w-5 text-green-600" />;
|
||||
case 'info':
|
||||
return <Eye className="h-5 w-5 text-blue-600" />;
|
||||
return <Info className="h-5 w-5 text-blue-600" />;
|
||||
default:
|
||||
return <Bell className="h-5 w-5 text-gray-600" />;
|
||||
}
|
||||
|
|
@ -160,11 +156,16 @@ export const Notifications = () => {
|
|||
const unreadCount = notifications.filter(n => !n.isRead).length;
|
||||
|
||||
return (
|
||||
<PageContainer>
|
||||
<PageTitle>اعلانات</PageTitle>
|
||||
<StatValue>
|
||||
<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">
|
||||
{unreadCount} اعلان خوانده نشده از {notifications.length} اعلان
|
||||
</StatValue>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center space-x-4">
|
||||
<Button
|
||||
|
|
@ -172,7 +173,7 @@ export const Notifications = () => {
|
|||
onClick={handleMarkAllAsRead}
|
||||
disabled={unreadCount === 0}
|
||||
>
|
||||
<BellOff className="h-4 w-4 ml-2" />
|
||||
<Check className="h-4 w-4 ml-2" />
|
||||
همه را خوانده شده علامت بزن
|
||||
</Button>
|
||||
<Button>
|
||||
|
|
@ -180,6 +181,7 @@ 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">
|
||||
|
|
@ -187,41 +189,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>
|
||||
<StatValue>{notifications.length}</StatValue>
|
||||
<p className="text-2xl font-bold text-gray-900 dark:text-gray-100">{notifications.length}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-white dark:bg-gray-800 p-4 rounded-lg shadow">
|
||||
<div className="flex items-center">
|
||||
<BellOff className="h-8 w-8 text-red-600" />
|
||||
<AlertCircle 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>
|
||||
<StatValue>{unreadCount}</StatValue>
|
||||
<p className="text-2xl font-bold text-gray-900 dark:text-gray-100">{unreadCount}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-white dark:bg-gray-800 p-4 rounded-lg shadow">
|
||||
<div className="flex items-center">
|
||||
<BellOff className="h-8 w-8 text-red-600" />
|
||||
<XCircle 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>
|
||||
<StatValue>
|
||||
<p className="text-2xl font-bold text-gray-900 dark:text-gray-100">
|
||||
{notifications.filter(n => n.type === 'error').length}
|
||||
</StatValue>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-white dark:bg-gray-800 p-4 rounded-lg shadow">
|
||||
<div className="flex items-center">
|
||||
<Bell className="h-8 w-8 text-yellow-600" />
|
||||
<AlertCircle 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>
|
||||
<StatValue>
|
||||
<p className="text-2xl font-bold text-gray-900 dark:text-gray-100">
|
||||
{notifications.filter(n => n.type === 'warning').length}
|
||||
</StatValue>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -306,7 +308,7 @@ export const Notifications = () => {
|
|||
variant="secondary"
|
||||
onClick={() => handleMarkAsRead(notification.id)}
|
||||
>
|
||||
<BellOff className="h-4 w-4" />
|
||||
<Check className="h-4 w-4" />
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
|
|
@ -314,7 +316,7 @@ export const Notifications = () => {
|
|||
variant="danger"
|
||||
onClick={() => handleDeleteNotification(notification.id)}
|
||||
>
|
||||
<BellOff className="h-4 w-4" />
|
||||
<X className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -337,6 +339,6 @@ export const Notifications = () => {
|
|||
totalItems={filteredNotifications.length}
|
||||
/>
|
||||
</div>
|
||||
</PageContainer>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
|
@ -0,0 +1,204 @@
|
|||
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>
|
||||
);
|
||||
};
|
||||
|
|
@ -1,12 +1,10 @@
|
|||
import { useState } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { Package, Plus, Search, Filter, Eye, Edit, Trash2, Grid, List } from 'lucide-react';
|
||||
import { Plus, Search, Filter, Package } 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: '۱۴۰۲/۰۸/۱۵' },
|
||||
|
|
@ -21,6 +19,7 @@ const allProducts = [
|
|||
|
||||
const Products = () => {
|
||||
const [searchTerm, setSearchTerm] = useState('');
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [currentPage, setCurrentPage] = useState(1);
|
||||
const itemsPerPage = 6;
|
||||
|
||||
|
|
@ -105,11 +104,16 @@ const Products = () => {
|
|||
};
|
||||
|
||||
return (
|
||||
<PageContainer>
|
||||
<PageTitle>مدیریت محصولات</PageTitle>
|
||||
<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">
|
||||
{filteredProducts.length} محصول یافت شد
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center space-x-4">
|
||||
<Button variant="secondary">
|
||||
|
|
@ -123,6 +127,7 @@ 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">
|
||||
|
|
@ -130,7 +135,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>
|
||||
<StatValue>{allProducts.length}</StatValue>
|
||||
<p className="text-2xl font-bold text-gray-900 dark:text-gray-100">{allProducts.length}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -140,9 +145,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>
|
||||
<StatValue>
|
||||
<p className="text-2xl font-bold text-gray-900 dark:text-gray-100">
|
||||
{allProducts.filter(p => p.status === 'موجود').length}
|
||||
</StatValue>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -152,9 +157,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>
|
||||
<StatValue>
|
||||
<p className="text-2xl font-bold text-gray-900 dark:text-gray-100">
|
||||
{allProducts.filter(p => p.status === 'ناموجود').length}
|
||||
</StatValue>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -180,7 +185,7 @@ const Products = () => {
|
|||
<Table
|
||||
columns={columns}
|
||||
data={paginatedProducts}
|
||||
loading={false}
|
||||
loading={loading}
|
||||
/>
|
||||
<Pagination
|
||||
currentPage={currentPage}
|
||||
|
|
@ -191,7 +196,7 @@ const Products = () => {
|
|||
/>
|
||||
</div>
|
||||
</div>
|
||||
</PageContainer>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -1,10 +1,8 @@
|
|||
import { useState } from 'react';
|
||||
import { FileText, Download, TrendingUp, Users, ShoppingBag, DollarSign } from 'lucide-react';
|
||||
import { FileText, Download, Calendar, TrendingUp, Users, ShoppingBag, DollarSign } from 'lucide-react';
|
||||
import { Button } from '../components/ui/Button';
|
||||
import { BarChart } from '../components/charts/BarChart';
|
||||
import { lazy, Suspense } from 'react';
|
||||
|
||||
const LineChart = lazy(() => import('../components/charts/LineChart').then(module => ({ default: module.LineChart })));
|
||||
import { LineChart } from '../components/charts/LineChart';
|
||||
|
||||
export const Reports = () => {
|
||||
const [selectedPeriod, setSelectedPeriod] = useState('month');
|
||||
|
|
@ -166,9 +164,7 @@ 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>
|
||||
|
||||
|
|
|
|||
|
|
@ -4,7 +4,6 @@ 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 = () => {
|
||||
|
|
@ -44,11 +43,15 @@ export const Settings = () => {
|
|||
|
||||
return (
|
||||
<div className="p-6 max-w-4xl mx-auto">
|
||||
<PageHeader
|
||||
title="تنظیمات سیستم"
|
||||
subtitle="تنظیمات کلی سیستم را اینجا مدیریت کنید"
|
||||
icon={SettingsIcon}
|
||||
/>
|
||||
<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>
|
||||
|
||||
<div className="grid grid-cols-1 lg:grid-cols-3 gap-8">
|
||||
<div className="lg:col-span-2">
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
import { useState } from 'react';
|
||||
import { Plus, Search, Filter, Users as UsersIcon, UserCheck, UserX } from 'lucide-react';
|
||||
import { Plus, Search, Filter } from 'lucide-react';
|
||||
import { Table } from '../components/ui/Table';
|
||||
import { Button } from '../components/ui/Button';
|
||||
import { Modal } from '../components/ui/Modal';
|
||||
|
|
@ -8,7 +8,6 @@ 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: '۰۹۱۲۳۴۵۶۷۸۹' },
|
||||
|
|
@ -27,6 +26,7 @@ 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,28 +112,27 @@ export const Users = () => {
|
|||
};
|
||||
|
||||
return (
|
||||
<PageContainer>
|
||||
<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>
|
||||
<PageTitle>مدیریت کاربران</PageTitle>
|
||||
<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">
|
||||
{filteredUsers.length} کاربر یافت شد
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center space-x-3 space-x-reverse">
|
||||
<div className="flex items-center space-x-4">
|
||||
<Button variant="secondary">
|
||||
<Filter className="h-4 w-4 ml-2" />
|
||||
فیلتر
|
||||
</Button>
|
||||
<PermissionWrapper permission={25}>
|
||||
<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>
|
||||
<Button onClick={handleAddUser}>
|
||||
<Plus className="h-4 w-4 ml-2" />
|
||||
افزودن کاربر
|
||||
</Button>
|
||||
</PermissionWrapper>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -158,7 +157,7 @@ export const Users = () => {
|
|||
<Table
|
||||
columns={columns}
|
||||
data={paginatedUsers}
|
||||
loading={false}
|
||||
loading={loading}
|
||||
/>
|
||||
<Pagination
|
||||
currentPage={currentPage}
|
||||
|
|
@ -171,7 +170,6 @@ export const Users = () => {
|
|||
</div>
|
||||
|
||||
<Modal
|
||||
title={editingUser ? "ویرایش کاربر" : "افزودن کاربر"}
|
||||
isOpen={showUserModal}
|
||||
onClose={handleCloseModal}
|
||||
size="lg"
|
||||
|
|
@ -180,10 +178,10 @@ export const Users = () => {
|
|||
initialData={editingUser}
|
||||
onSubmit={handleSubmitUser}
|
||||
onCancel={handleCloseModal}
|
||||
loading={false}
|
||||
loading={loading}
|
||||
isEdit={!!editingUser}
|
||||
/>
|
||||
</Modal>
|
||||
</PageContainer>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
|
@ -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,6 +20,7 @@ const Users = () => {
|
|||
const itemsPerPage = 5;
|
||||
|
||||
const { filters, setFilters } = useFilters();
|
||||
const toast = useToast();
|
||||
|
||||
const queryParams = {
|
||||
page: currentPage,
|
||||
|
|
@ -59,7 +60,7 @@ const Users = () => {
|
|||
key: 'createdAt',
|
||||
label: 'تاریخ عضویت',
|
||||
sortable: true,
|
||||
render: (value) => formatDate(value)
|
||||
render: (value) => new Date(value).toLocaleDateString('fa-IR')
|
||||
},
|
||||
{
|
||||
key: 'actions',
|
||||
|
|
@ -176,20 +177,19 @@ const Users = () => {
|
|||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center space-x-3 space-x-reverse">
|
||||
<div className="flex items-center space-x-4">
|
||||
<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-5 w-5" />
|
||||
</button>
|
||||
<Plus className="h-4 w-4 ml-2" />
|
||||
افزودن کاربر
|
||||
</Button>
|
||||
</PermissionWrapper>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -211,9 +211,7 @@ const Users = () => {
|
|||
</div>
|
||||
|
||||
{isLoading ? (
|
||||
<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>
|
||||
<LoadingSpinner />
|
||||
) : (
|
||||
<>
|
||||
<div className="bg-white dark:bg-gray-800 rounded-lg overflow-hidden">
|
||||
|
|
@ -235,7 +233,6 @@ const Users = () => {
|
|||
</div>
|
||||
|
||||
<Modal
|
||||
title={editingUser ? "ویرایش کاربر" : "افزودن کاربر"}
|
||||
isOpen={showUserModal}
|
||||
onClose={handleCloseModal}
|
||||
size="lg"
|
||||
|
|
|
|||
|
|
@ -1,238 +0,0 @@
|
|||
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;
|
||||
|
|
@ -1,273 +0,0 @@
|
|||
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;
|
||||
|
|
@ -1,243 +0,0 @@
|
|||
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;
|
||||
|
|
@ -1,90 +0,0 @@
|
|||
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 || "خطا در حذف کاربر ادمین");
|
||||
},
|
||||
});
|
||||
};
|
||||
|
|
@ -1,50 +0,0 @@
|
|||
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";
|
||||
|
|
@ -1,154 +0,0 @@
|
|||
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;
|
||||
}
|
||||
};
|
||||
|
|
@ -1,30 +0,0 @@
|
|||
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 || "خطا در ورود");
|
||||
},
|
||||
});
|
||||
};
|
||||
|
|
@ -1,18 +0,0 @@
|
|||
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");
|
||||
};
|
||||
|
|
@ -1,19 +0,0 @@
|
|||
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;
|
||||
};
|
||||
|
|
@ -1,203 +0,0 @@
|
|||
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;
|
||||
|
|
@ -1,219 +0,0 @@
|
|||
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;
|
||||
|
|
@ -1,98 +0,0 @@
|
|||
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 || "خطا در حذف دستهبندی");
|
||||
},
|
||||
});
|
||||
};
|
||||
|
|
@ -1,54 +0,0 @@
|
|||
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;
|
||||
}
|
||||
|
|
@ -1,84 +0,0 @@
|
|||
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;
|
||||
};
|
||||
|
|
@ -1,173 +0,0 @@
|
|||
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;
|
||||
|
|
@ -1,28 +0,0 @@
|
|||
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
Loading…
Reference in New Issue